From 236b1b56cdd18da14bc1015233e117b03003e8f1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:29:25 -0700 Subject: [PATCH 01/71] =?UTF-8?q?feat(desktop):=20artifacts=20=E2=80=94=20?= =?UTF-8?q?versioned=20cards,=20sandboxed=20live=20preview,=20right-rail?= =?UTF-8?q?=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substantial generated content (full HTML documents, large SVGs, long code) now promotes out of the transcript into versioned, openable artifacts: - lib/artifact-detect.ts: pure detection over fenced blocks — html docs, large standalone svg, and 48+ line / 3k+ char code fences become artifacts; prose/terminal/mermaid fences and small snippets never do. Titles derive from /<h1>, filename comments, or declarations. - store/artifacts.ts: per-session registry with content-hash dedupe and version history (same kind+title = one artifact the model iterates on), persisted to localStorage with per-session/per-artifact/byte caps. - ArtifactCard (transcript): compact openable card replaces the wall of code; streaming shows shimmer + line count; versions accumulate automatically on completion but the rail only opens on click (offer, don't hijack). Reasoning scratchpads never register. - ArtifactPane (right rail): artifact: tabs beside preview/file tabs with version stepper (v2 of 3 / Latest), PREVIEW/SOURCE toggle, copy, download (kind-aware extension), open-in-browser for HTML. - Rendering: HTML runs in a sandbox=allow-scripts iframe (opaque origin, no network to the app, no top-nav); SVG is DOMPurify-sanitized with the same profile as the inline embed; source view reuses the windowed Shiki renderer so 5k-line artifacts scroll smoothly. - Rail integration: artifact tabs participate in tab order, close-others/ close-to-right, ⌘W, pane visibility, and reveal; gateway switches close open artifact tabs; a persisted artifact: active-tab id reconciles to preview on boot (artifact tabs are ephemeral). - i18n: en/zh/zh-hant/ja/ar strings for card + pane. - session-ref-open.test.tsx: mock now spreads the real module — the artifact card imports session-view, which needs $sessionStates. Tests: artifact-detect (15), artifacts store (12), markdown-pipeline integration (3); full desktop suite 3128 passed, electron project 751 passed, tsc no new errors. --- apps/desktop/src/app/chat/close-tab.ts | 3 +- .../src/app/chat/right-rail/artifact-pane.tsx | 222 ++++++++++ .../chat/right-rail/artifact-renderers.tsx | 104 +++++ .../src/app/chat/right-rail/preview.tsx | 69 +++- apps/desktop/src/app/contrib/controller.tsx | 21 +- apps/desktop/src/app/contrib/panes.tsx | 4 +- .../components/assistant-ui/artifact-card.tsx | 145 +++++++ .../markdown-text.artifacts.test.tsx | 74 ++++ .../components/assistant-ui/markdown-text.tsx | 44 +- .../assistant-ui/session-ref-open.test.tsx | 3 +- .../assistant-ui/thread/message-parts.tsx | 1 + apps/desktop/src/i18n/ar.ts | 23 ++ apps/desktop/src/i18n/en.ts | 23 ++ apps/desktop/src/i18n/ja.ts | 23 ++ apps/desktop/src/i18n/types.ts | 23 ++ apps/desktop/src/i18n/zh-hant.ts | 23 ++ apps/desktop/src/i18n/zh.ts | 23 ++ apps/desktop/src/lib/artifact-detect.test.ts | 121 ++++++ apps/desktop/src/lib/artifact-detect.ts | 232 +++++++++++ apps/desktop/src/lib/download-text.ts | 16 + apps/desktop/src/store/artifacts.test.ts | 170 ++++++++ apps/desktop/src/store/artifacts.ts | 386 ++++++++++++++++++ apps/desktop/src/store/gateway-switch.ts | 5 + apps/desktop/src/store/layout.ts | 2 +- apps/desktop/src/store/preview.ts | 50 ++- 25 files changed, 1763 insertions(+), 47 deletions(-) create mode 100644 apps/desktop/src/app/chat/right-rail/artifact-pane.tsx create mode 100644 apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx create mode 100644 apps/desktop/src/components/assistant-ui/artifact-card.tsx create mode 100644 apps/desktop/src/components/assistant-ui/markdown-text.artifacts.test.tsx create mode 100644 apps/desktop/src/lib/artifact-detect.test.ts create mode 100644 apps/desktop/src/lib/artifact-detect.ts create mode 100644 apps/desktop/src/lib/download-text.ts create mode 100644 apps/desktop/src/store/artifacts.test.ts create mode 100644 apps/desktop/src/store/artifacts.ts diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts index 098e59108aea..b5e15dd30cf1 100644 --- a/apps/desktop/src/app/chat/close-tab.ts +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -1,6 +1,7 @@ import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals' import { closeWorkspaceTab } from '@/components/pane-shell/tree/store' import { isFocusWithin } from '@/lib/keybinds/combo' +import { $artifactTabs } from '@/store/artifacts' import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview' import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states' @@ -32,7 +33,7 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri // file tabs remain (the rail UI falls back to tabs[0]). Gating only on // `$filePreviewTarget` made ⌘W fall through to closeWorkspaceTab() and look // broken with a file tab still on screen. - if ($previewTarget.get() || $filePreviewTabs.get().length > 0) { + if ($previewTarget.get() || $filePreviewTabs.get().length > 0 || $artifactTabs.get().length > 0) { return closeActiveRightRailTab() } diff --git a/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx b/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx new file mode 100644 index 000000000000..c463ee84c41a --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx @@ -0,0 +1,222 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useMemo, useState } from 'react' + +import { CopyButton } from '@/components/ui/copy-button' +import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' +import { artifactDownloadName } from '@/lib/artifact-detect' +import { downloadTextFile } from '@/lib/download-text' +import { ChevronLeft, ChevronRight, Download, ExternalLink } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $artifactRegistry, + $artifactVersionSelection, + type ArtifactRecord, + selectArtifactVersion +} from '@/store/artifacts' +import { notifyError } from '@/store/notifications' + +import { ArtifactLivePreview, ArtifactSourceView, composeArtifactHtml } from './artifact-renderers' +import { PreviewEmptyState } from './preview-file' + +type ArtifactViewMode = 'preview' | 'source' + +const MIME_BY_KIND = { code: 'text/plain', html: 'text/html', svg: 'image/svg+xml' } as const + +const HEADER_BUTTON_CLASS = + 'flex h-5 items-center gap-1 rounded-md px-1 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40' + +/** Write the composed document to a real temp file through the existing + * buffer-save IPC, then hand it to the OS browser. A blob/data URL can't + * cross into the OS default browser, so a file on disk is the honest path. */ +async function openHtmlInBrowser(content: string): Promise<void> { + const bridge = window.hermesDesktop + + if (!bridge?.saveImageBuffer || !bridge.openExternal) { + throw new Error('Desktop bridge unavailable') + } + + const bytes = new TextEncoder().encode(composeArtifactHtml(content)) + const path = await bridge.saveImageBuffer(bytes, '.html') + + if (!path) { + throw new Error('Could not write artifact file') + } + + const fileUrl = `file://${path.startsWith('/') ? '' : '/'}${path.replace(/\\/g, '/')}` + + if (bridge.openPreviewInBrowser) { + await bridge.openPreviewInBrowser(fileUrl) + + return + } + + await bridge.openExternal(fileUrl) +} + +function VersionStepper({ + current, + onSelect, + total +}: { + current: number + onSelect: (index: number) => void + total: number +}) { + const { t } = useI18n() + const copy = t.artifactPane + + if (total < 2) { + return null + } + + return ( + <div className="flex items-center gap-0.5 text-[0.625rem] font-bold text-muted-foreground"> + <Tip label={copy.olderVersion}> + <button + aria-label={copy.olderVersion} + className={HEADER_BUTTON_CLASS} + disabled={current === 0} + onClick={() => onSelect(current - 1)} + type="button" + > + <ChevronLeft className="size-3" /> + </button> + </Tip> + <span className="tabular-nums">{copy.versionOf(current + 1, total)}</span> + <Tip label={copy.newerVersion}> + <button + aria-label={copy.newerVersion} + className={HEADER_BUTTON_CLASS} + disabled={current === total - 1} + onClick={() => onSelect(current + 1)} + type="button" + > + <ChevronRight className="size-3" /> + </button> + </Tip> + </div> + ) +} + +export function ArtifactPane({ artifactId }: { artifactId: string }) { + const { t } = useI18n() + const copy = t.artifactPane + const registry = useStore($artifactRegistry) + const versionSelection = useStore($artifactVersionSelection) + // View mode is per-pane, ephemeral: renderable artifacts open in preview. + const [userMode, setUserMode] = useState<ArtifactViewMode | null>(null) + + // Reset the explicit mode when the pane is reused for another artifact. + useEffect(() => { + setUserMode(null) + }, [artifactId]) + + const record = useMemo<ArtifactRecord | null>(() => { + for (const records of Object.values(registry)) { + const found = records.find(candidate => candidate.id === artifactId) + + if (found) { + return found + } + } + + return null + }, [artifactId, registry]) + + if (!record) { + return <PreviewEmptyState body={copy.missingBody} title={copy.missingTitle} /> + } + + const isRenderable = record.kind === 'html' || record.kind === 'svg' + const versionIndex = Math.min(versionSelection[artifactId] ?? record.versions.length - 1, record.versions.length - 1) + const version = record.versions[versionIndex]! + const isCurrentVersion = versionIndex >= record.versions.length - 1 + const mode: ArtifactViewMode = isRenderable ? (userMode ?? 'preview') : 'source' + const downloadName = artifactDownloadName(record.kind, record.language, record.title) + + const modeLabel: Record<ArtifactViewMode, string> = { + preview: copy.modePreview, + source: copy.modeSource + } + + return ( + <div className="flex h-full flex-col overflow-hidden bg-transparent"> + <div className="flex h-7 shrink-0 items-center gap-3 border-b border-border/40 px-3"> + <div className="flex min-w-0 flex-1 items-center gap-2"> + <VersionStepper + current={versionIndex} + onSelect={index => selectArtifactVersion(artifactId, index)} + total={record.versions.length} + /> + {!isCurrentVersion && ( + <button + className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/25 underline-offset-4 transition-colors hover:text-foreground" + onClick={() => selectArtifactVersion(artifactId, record.versions.length - 1)} + type="button" + > + {copy.latest} + </button> + )} + </div> + {isRenderable && + (['preview', 'source'] as const).map(candidate => ( + <button + className={cn( + 'text-[0.625rem] font-bold underline-offset-4 transition-colors', + candidate === mode + ? 'text-foreground underline decoration-current/30' + : 'text-muted-foreground hover:text-foreground' + )} + key={candidate} + onClick={() => setUserMode(candidate)} + type="button" + > + {modeLabel[candidate]} + </button> + ))} + <div className="flex items-center gap-0.5"> + <CopyButton + appearance="inline" + className="h-5 px-1 opacity-70 hover:opacity-100" + iconClassName="size-3" + label={copy.copyContent} + showLabel={false} + text={version.content} + /> + <Tip label={copy.download}> + <button + aria-label={copy.download} + className={HEADER_BUTTON_CLASS} + onClick={() => downloadTextFile(downloadName, version.content, MIME_BY_KIND[record.kind])} + type="button" + > + <Download className="size-3" /> + </button> + </Tip> + {record.kind === 'html' && window.hermesDesktop && ( + <Tip label={copy.openInBrowser}> + <button + aria-label={copy.openInBrowser} + className={HEADER_BUTTON_CLASS} + onClick={() => + void openHtmlInBrowser(version.content).catch(error => notifyError(error, copy.openInBrowserFailed)) + } + type="button" + > + <ExternalLink className="size-3" /> + </button> + </Tip> + )} + </div> + </div> + <div className="min-h-0 flex-1 overflow-hidden"> + {mode === 'preview' && isRenderable ? ( + <ArtifactLivePreview content={version.content} kind={record.kind} title={record.title} /> + ) : ( + <ArtifactSourceView language={record.kind === 'html' ? 'html' : record.language} text={version.content} /> + )} + </div> + </div> + ) +} diff --git a/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx b/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx new file mode 100644 index 000000000000..61b49809265a --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx @@ -0,0 +1,104 @@ +import DOMPurify from 'dompurify' +import { useMemo } from 'react' +import ShikiHighlighter from 'react-shiki' + +import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window' +import type { ArtifactKind } from '@/lib/artifact-detect' + +const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const +const SOURCE_CHUNK_LINES = 200 +const SOURCE_LINE_PX = 20 +const SOURCE_OVERSCAN_LINES = 400 + +/** Windowed, Shiki-highlighted source view for artifact content. Same fixed-row + * windowing as the file preview's SourceView so a 5k-line artifact scrolls + * smoothly, minus the gutter drag/selection machinery (artifact content has no + * on-disk path to reference lines against). */ +export function ArtifactSourceView({ language, text }: { language: string; text: string }) { + const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text]) + const lastChunk = chunks.at(-1) + const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0 + + const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({ + overscanRows: SOURCE_OVERSCAN_LINES, + rowPx: SOURCE_LINE_PX, + rowsPerChunk: SOURCE_CHUNK_LINES, + totalRows: totalLines + }) + + const visibleChunks = chunks.slice(startChunk, endChunk + 1) + + return ( + <div className="h-full overflow-auto" onScroll={onScroll} ref={scrollerRef}> + <div className="min-w-max px-3 py-2 font-mono text-[0.7rem] leading-relaxed" data-selectable-text="true"> + {beforeRows > 0 && <div aria-hidden style={{ height: beforeRows * SOURCE_LINE_PX }} />} + {visibleChunks.map(chunk => ( + <div className="[&_pre]:m-0" key={chunk.start}> + <ShikiHighlighter + addDefaultStyles={false} + as="div" + defaultColor="light-dark()" + delay={80} + language={language || 'text'} + showLanguage={false} + theme={SHIKI_THEME} + > + {chunk.text} + </ShikiHighlighter> + </div> + ))} + {afterRows > 0 && <div aria-hidden style={{ height: afterRows * SOURCE_LINE_PX }} />} + </div> + </div> + ) +} + +/** Wrap an HTML fragment in a minimal document shell; full documents pass + * through untouched. Keeps generated fragments (no <html>/<body>) rendering + * with sane defaults instead of quirks-mode soup. */ +export function composeArtifactHtml(content: string): string { + if (/<html[\s>]|<!doctype\s+html/i.test(content)) { + return content + } + + return [ + '<!doctype html>', + '<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">', + '<style>body{margin:0;font-family:system-ui,sans-serif}</style></head><body>', + content, + '</body></html>' + ].join('\n') +} + +/** + * Sandboxed live renderer for html/svg artifact content. + * + * HTML runs in an `<iframe sandbox="allow-scripts">` — scripts execute in an + * opaque origin with no same-origin access, no top navigation, no popups, no + * form submission out of the frame. The parent app is unreachable. SVG is + * DOMPurify-sanitized with the same profile as the inline ```svg embed. + */ +export function ArtifactLivePreview({ content, kind, title }: { content: string; kind: ArtifactKind; title: string }) { + const svgClean = useMemo( + () => (kind === 'svg' ? DOMPurify.sanitize(content, { USE_PROFILES: { svg: true, svgFilters: true } }) : ''), + [content, kind] + ) + + if (kind === 'svg') { + return ( + <div className="grid h-full place-items-center overflow-auto bg-background p-4 [&_svg]:h-auto [&_svg]:max-h-full [&_svg]:w-auto [&_svg]:max-w-full"> + <div dangerouslySetInnerHTML={{ __html: svgClean }} /> + </div> + ) + } + + return ( + <iframe + className="block size-full border-0 bg-white" + sandbox="allow-scripts" + srcDoc={composeArtifactHtml(content)} + style={{ colorScheme: 'light' }} + title={title} + /> + ) +} diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index 31fc5f6d6a74..219a28aacfc8 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -15,6 +15,7 @@ import { Tip } from '@/components/ui/tooltip' import { translateNow, useI18n } from '@/i18n' import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' +import { $openArtifacts, artifactIdFromTabId } from '@/store/artifacts' import { $panesFlipped, $rightRailActiveTabId, @@ -34,6 +35,7 @@ import { } from '@/store/preview' import { $dirtyPreviewUrls } from '@/store/preview-edit' +import { ArtifactPane } from './artifact-pane' import { PreviewPane } from './preview-pane' export const PREVIEW_RAIL_MIN_WIDTH = '18rem' @@ -44,11 +46,9 @@ interface ChatPreviewRailProps { setTitlebarToolGroup?: SetTitlebarToolGroup } -interface RailTab { - id: RightRailTabId - label: string - target: PreviewTarget -} +type RailTab = + | { id: RightRailTabId; kind: 'preview'; label: string; target: PreviewTarget; tooltip: string } + | { artifactId: string; id: RightRailTabId; kind: 'artifact'; label: string; tooltip: string } function tabLabelFor(target: PreviewTarget): string { const value = target.label || target.path || target.source || target.url @@ -65,15 +65,43 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP const filePreviewTabs = useStore($filePreviewTabs) const previewTarget = useStore($previewTarget) const dirtyPreviewUrls = useStore($dirtyPreviewUrls) + const openArtifacts = useStore($openArtifacts) const tabs = useMemo<readonly RailTab[]>( () => [ ...(previewTarget - ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab] + ? [ + { + id: RIGHT_RAIL_PREVIEW_TAB_ID, + kind: 'preview', + label: t.preview.tab, + target: previewTarget, + tooltip: previewTarget.path || previewTarget.url || t.preview.tab + } as RailTab + ] : []), - ...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab) + ...filePreviewTabs.map( + ({ id, target }) => + ({ + id, + kind: 'preview', + label: tabLabelFor(target), + target, + tooltip: target.path || target.url || tabLabelFor(target) + }) as RailTab + ), + ...openArtifacts.map( + ({ record, tabId }) => + ({ + artifactId: record.id, + id: tabId, + kind: 'artifact', + label: record.title || t.artifactPane.tabFallback, + tooltip: record.title || t.artifactPane.tabFallback + }) as RailTab + ) ], - [filePreviewTabs, previewTarget, t.preview.tab] + [filePreviewTabs, openArtifacts, previewTarget, t.artifactPane.tabFallback, t.preview.tab] ) const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0] @@ -117,13 +145,13 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP const active = tab.id === activeTab.id const hasOthers = tabs.length > 1 const hasTabsToRight = index < tabs.length - 1 - const dirty = Boolean(dirtyPreviewUrls[tab.target.url]) + const dirty = tab.kind === 'preview' && Boolean(dirtyPreviewUrls[tab.target.url]) return ( <ContextMenu key={tab.id}> <ContextMenuTrigger asChild> <PaneTab active={active} dirty={dirty} onClose={() => closeRightRailTab(tab.id)}> - <Tip label={tab.target.path || tab.target.url || tab.label}> + <Tip label={tab.tooltip}> <PaneTabLabel aria-selected={active} as="button" @@ -132,6 +160,9 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP role="tab" type="button" > + {tab.kind === 'artifact' && ( + <Codicon className="mr-1 shrink-0 text-[0.6875rem] opacity-70" name="sparkle" /> + )} {tab.label} </PaneTabLabel> </Tip> @@ -166,13 +197,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP </div> <div className="min-h-0 flex-1 overflow-hidden"> - <PreviewPane - embedded - onRestartServer={isPreview ? onRestartServer : undefined} - reloadRequest={previewReloadRequest} - setTitlebarToolGroup={setTitlebarToolGroup} - target={activeTab.target} - /> + {activeTab.kind === 'artifact' ? ( + <ArtifactPane artifactId={artifactIdFromTabId(activeTab.id) ?? activeTab.artifactId} /> + ) : ( + <PreviewPane + embedded + onRestartServer={isPreview ? onRestartServer : undefined} + reloadRequest={previewReloadRequest} + setTitlebarToolGroup={setTitlebarToolGroup} + target={activeTab.target} + /> + )} </div> </aside> ) diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 3c245478bb09..8f486accb56d 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -39,6 +39,7 @@ import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime' import { LayoutDashboard } from '@/lib/icons' import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' import { Codecs, persistentAtom } from '@/lib/persisted' +import { $artifactTabs } from '@/store/artifacts' import { $fileBrowserOpen, $panesFlipped, @@ -51,7 +52,7 @@ import { SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH } from '@/store/layout' -import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview' +import { $filePreviewTabs, $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview' import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review' import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session' import { watchSessionPins } from '@/store/session-pin-sync' @@ -552,9 +553,10 @@ bindPaneCollapse( // Preview EXISTS only while something is previewed (old-shell semantics: // closing the last preview tab closes the pane; a new target opens + fronts // it). Same visibility binding as every other self-managed surface, driven -// by the live targets instead of a toggle. -const $previewVisible = computed([$previewTarget, $filePreviewTarget], (target, fileTarget) => - Boolean(target || fileTarget) +// by the live targets (and open artifact tabs) instead of a toggle. +const $previewVisible = computed( + [$previewTarget, $filePreviewTabs, $artifactTabs], + (target, fileTabs, artifactTabs) => Boolean(target) || fileTabs.length > 0 || artifactTabs.length > 0 ) bindPaneVisibility('preview', $previewVisible, closeRightRail) @@ -603,6 +605,17 @@ const revealPreview = () => { $previewTarget.listen(target => target && revealPreview()) $filePreviewTarget.listen(target => target && revealPreview()) +// Artifact reveal keys on tab OPENS (length grows), not list identity — closing +// one of two artifact tabs must not re-front the pane. +let lastArtifactTabCount = $artifactTabs.get().length +$artifactTabs.listen(tabs => { + const grew = tabs.length > lastArtifactTabCount + lastArtifactTabCount = tabs.length + + if (grew) { + revealPreview() + } +}) // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/app/contrib/panes.tsx b/apps/desktop/src/app/contrib/panes.tsx index 484b1025eddd..21c18d358f17 100644 --- a/apps/desktop/src/app/contrib/panes.tsx +++ b/apps/desktop/src/app/contrib/panes.tsx @@ -27,6 +27,7 @@ import { registry } from '@/contrib/registry' import { getLogs } from '@/hermes' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { cn } from '@/lib/utils' +import { $openArtifacts } from '@/store/artifacts' import { $filePreviewTarget, $previewTarget, setCurrentSessionPreviewTarget } from '@/store/preview' import { $currentCwd } from '@/store/session' @@ -74,9 +75,10 @@ export const $restartPreviewServer = atom<((url: string, context?: string) => Pr export function PreviewRailPane() { const previewTarget = useStore($previewTarget) const fileTarget = useStore($filePreviewTarget) + const openArtifacts = useStore($openArtifacts) const restartPreviewServer = useStore($restartPreviewServer) - if (!previewTarget && !fileTarget) { + if (!previewTarget && !fileTarget && openArtifacts.length === 0) { return ( <div className="grid h-full place-items-center px-4 text-center"> <div className="flex flex-col items-center gap-1.5"> diff --git a/apps/desktop/src/components/assistant-ui/artifact-card.tsx b/apps/desktop/src/components/assistant-ui/artifact-card.tsx new file mode 100644 index 000000000000..7444c23c05f0 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/artifact-card.tsx @@ -0,0 +1,145 @@ +'use client' + +import { useStore } from '@nanostores/react' +import { useEffect, useMemo } from 'react' + +import { useSessionView } from '@/app/chat/session-view' +import { CodeCardIcon } from '@/components/chat/code-card' +import { useI18n } from '@/i18n' +import type { ArtifactDetection } from '@/lib/artifact-detect' +import { codiconForLanguage } from '@/lib/markdown-code' +import { cn } from '@/lib/utils' +import { + $artifactRegistry, + artifactsForSession, + openArtifactTab, + selectArtifactVersion, + upsertArtifact +} from '@/store/artifacts' + +interface ArtifactCardProps { + code: string + detection: ArtifactDetection + streaming?: boolean +} + +const KIND_ICON: Record<ArtifactDetection['kind'], string> = { + code: 'code', + html: 'browser', + svg: 'symbol-color' +} + +function detectionIcon(detection: ArtifactDetection): string { + return detection.kind === 'code' ? codiconForLanguage(detection.language) : KIND_ICON[detection.kind] +} + +/** + * Transcript stand-in for a fenced block that was promoted to an artifact. + * Replaces the wall of code with a compact, openable card: icon, title, kind, + * version badge. While the fence is still streaming + * it shows a shimmer + line count instead of the growing source. + * + * Registration is automatic on completion (so version history accumulates + * even if the user never opens the card) but opening the rail is strictly + * click-driven — a background stream never steals the pane. + */ +export function ArtifactCard({ code, detection, streaming = false }: ArtifactCardProps) { + const { t } = useI18n() + const copy = t.artifactCard + const view = useSessionView() + const runtimeId = useStore(view.$runtimeId) + const storedId = useStore(view.$storedId) + const registry = useStore($artifactRegistry) + const sessionId = storedId || runtimeId || '' + + const trimmed = code.trim() + + // Register/version the artifact once its fence has finished streaming. + // upsertArtifact dedupes on content hash, so re-renders and transcript + // replays are no-ops. + useEffect(() => { + if (!streaming && sessionId && trimmed) { + upsertArtifact(sessionId, detection, trimmed) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- detection derives from code + }, [detection.kind, detection.language, detection.title, sessionId, streaming, trimmed]) + + const record = useMemo(() => { + void registry + + const slugMatch = artifactsForSession(sessionId).find( + candidate => candidate.kind === detection.kind && candidate.versions.some(v => v.content === trimmed) + ) + + return slugMatch ?? null + }, [detection.kind, registry, sessionId, trimmed]) + + const lineCount = useMemo(() => trimmed.split('\n').length, [trimmed]) + const kindLabel = copy.kind[detection.kind] + const versionCount = record?.versions.length ?? 0 + const title = (record?.title || detection.title || kindLabel).trim() || kindLabel + + const open = () => { + if (streaming || !sessionId || !trimmed) { + return + } + + // Ensure the registry row exists even if the completion effect hasn't + // fired yet (e.g. clicked in the same frame the stream sealed). + const result = upsertArtifact(sessionId, detection, trimmed) + + if (!result) { + return + } + + openArtifactTab(result.artifactId) + + // An older card opens at ITS version, not silently the newest — the user + // clicked this specific iteration. openArtifactTab resets to newest, so + // re-pin when this card's content is a historical version. + const versionIndex = result.record.versions.findIndex(version => version.content === trimmed) + + if (versionIndex !== -1 && versionIndex < result.record.versions.length - 1) { + selectArtifactVersion(result.artifactId, versionIndex) + } + } + + return ( + <button + className={cn( + 'group/artifact my-1.5 flex w-full max-w-md items-center gap-2.5 overflow-hidden rounded-[0.625rem] border border-border px-3 py-2.5 text-left transition-colors', + streaming ? 'cursor-default' : 'cursor-pointer hover:border-border hover:bg-accent/40' + )} + data-slot="aui_artifact-card" + disabled={streaming} + onClick={open} + type="button" + > + <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground"> + <CodeCardIcon className="text-[1rem]" name={detectionIcon(detection)} /> + </span> + <span className="min-w-0 flex-1"> + <span + className={cn( + 'block truncate text-[0.8125rem] font-medium text-foreground', + streaming && 'shimmer text-foreground/55' + )} + > + {title} + </span> + <span className="block truncate text-[0.6875rem] text-muted-foreground"> + {streaming + ? copy.generating(lineCount) + : versionCount > 1 + ? `${kindLabel} · ${copy.versionBadge(versionCount)}` + : kindLabel} + </span> + </span> + {!streaming && ( + <span className="shrink-0 text-[0.6875rem] font-medium text-muted-foreground opacity-0 transition-opacity group-hover/artifact:opacity-100"> + {copy.open} + </span> + )} + </button> + ) +} diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.artifacts.test.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.artifacts.test.tsx new file mode 100644 index 000000000000..5aa9b66f11ac --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/markdown-text.artifacts.test.tsx @@ -0,0 +1,74 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { $artifactTabs, artifactsForSession, clearArtifactRegistry } from '@/store/artifacts' +import { $activeSessionId, $selectedStoredSessionId } from '@/store/session' + +import { MarkdownTextContent } from './markdown-text' + +const HTML_DOC = `<!doctype html> +<html> +<head><title>Pomodoro Timer + +

Pomodoro

+

A tiny focus timer that counts down twenty-five minutes.

+ + +` + +const SMALL_SNIPPET = 'const x = 1' + +function fenced(language: string, body: string): string { + return `Here you go:\n\n\`\`\`${language}\n${body}\n\`\`\`\n` +} + +// End-to-end for the artifact path: a substantial ```html fence in assistant +// markdown must come out of preprocessMarkdown -> Streamdown -> SyntaxHighlighter +// as an artifact card (registered in the store), while small fences keep the +// plain code-card path. +describe('MarkdownTextContent artifacts', () => { + beforeEach(() => { + $activeSessionId.set('session-artifacts') + $selectedStoredSessionId.set(null) + window.localStorage.clear() + clearArtifactRegistry() + }) + + afterEach(() => { + cleanup() + $activeSessionId.set(null) + $selectedStoredSessionId.set(null) + clearArtifactRegistry() + window.localStorage.clear() + }) + + it('renders a substantial html fence as an artifact card and registers it', async () => { + render() + + const card = await screen.findByText('Pomodoro Timer') + + expect(card.closest('button')?.dataset.slot).toBe('aui_artifact-card') + expect(artifactsForSession('session-artifacts')).toHaveLength(1) + expect(artifactsForSession('session-artifacts')[0]?.kind).toBe('html') + // Registration alone must not open the rail (offer, don't hijack). + expect($artifactTabs.get()).toHaveLength(0) + }) + + it('keeps a small fence as a plain code block', async () => { + const { container } = render() + + // The code card mounts synchronously; Shiki may split tokens into spans, + // so assert on the card slots rather than text content. + expect(container.querySelector('[data-slot="code-card"]')).not.toBeNull() + expect(container.querySelector('[data-slot="aui_artifact-card"]')).toBeNull() + expect(artifactsForSession('session-artifacts')).toHaveLength(0) + }) + + it('does not register while the message is still streaming', async () => { + render() + + await screen.findByText('Pomodoro Timer') + + expect(artifactsForSession('session-artifacts')).toHaveLength(0) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index f641890ffd40..f11247fbfa3f 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -14,6 +14,7 @@ import { ExpandableBlock } from '@/components/chat/expandable-block' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter' import { ZoomableImage } from '@/components/chat/zoomable-image' +import { detectArtifact } from '@/lib/artifact-detect' import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link' import { createMemoizedMathPlugin } from '@/lib/katex-memo' import { parseMarkdownIntoBlocksCached } from '@/lib/markdown-blocks' @@ -33,6 +34,7 @@ import { previewTargetFromMarkdownHref } from '@/lib/preview-targets' import { sessionRefFromMarkdownHref } from '@/lib/session-refs' import { cn } from '@/lib/utils' +import { ArtifactCard } from './artifact-card' import { SessionRefLink } from './directive-text' import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds' @@ -355,6 +357,9 @@ interface MarkdownTextSurfaceProps { containerClassName?: string containerProps?: ComponentProps<'div'> defer?: boolean + /** Disable artifact-card promotion for fenced blocks (reasoning text — a + * model's scratchpad draft must not register artifact versions). */ + disableArtifacts?: boolean } // Headings shrink to chat scale rather than the prose default (h1≈xl). Kept @@ -403,7 +408,12 @@ function HugeTextFallback({ containerClassName, text }: { containerClassName?: s ) } -function MarkdownTextSurface({ containerClassName, containerProps, defer }: MarkdownTextSurfaceProps) { +function MarkdownTextSurface({ + containerClassName, + containerProps, + defer, + disableArtifacts +}: MarkdownTextSurfaceProps) { const { status, text } = useMessagePartText() const isStreaming = status.type === 'running' @@ -508,18 +518,28 @@ function MarkdownTextSurface({ containerClassName, containerProps, defer }: Mark ), img: MarkdownImage, - // ```mermaid / ```svg fences route to their lazy renderers; every other - // language falls back to the Shiki-highlighted code block. - SyntaxHighlighter: (props: SyntaxHighlighterProps) => ( - } - language={props.language} - streaming={isStreaming} - /> - ) + // ```mermaid / ```svg fences route to their lazy renderers; substantial + // html/svg/code fences promote to an artifact card that opens in the + // right rail; every other language falls back to the Shiki-highlighted + // code block. + SyntaxHighlighter: (props: SyntaxHighlighterProps) => { + const artifact = disableArtifacts ? null : detectArtifact(props.language, props.code) + + if (artifact) { + return + } + + return ( + } + language={props.language} + streaming={isStreaming} + /> + ) + } }) as StreamdownTextComponents, - [isStreaming] + [disableArtifacts, isStreaming] ) if (text.length > MAX_MARKDOWN_CHARS) { diff --git a/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx b/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx index de3674f529ca..1dad2f730eb9 100644 --- a/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx +++ b/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx @@ -8,7 +8,8 @@ import { MarkdownTextContent } from './markdown-text' const openSessionTile = vi.fn() -vi.mock('@/store/session-states', () => ({ +vi.mock('@/store/session-states', async importOriginal => ({ + ...(await importOriginal>()), openSessionTile: (...args: unknown[]) => openSessionTile(...args) })) diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index ac0b42423e9f..d4f9967d0bd8 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -198,6 +198,7 @@ const ReasoningTextPart: ReasoningMessagePartComponent = () => { } + disableArtifacts isRunning={status.type === 'running' || messageRunning} text={text.trimStart()} /> diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index fb0c4035a51f..cb4a8d74fc0e 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1495,6 +1495,29 @@ export const ar = defineLocale({ copyUrl: 'نسخ الرابط', copyPath: 'نسخ المسار' }, + + artifactCard: { + kind: { code: 'كود', html: 'صفحة تفاعلية', svg: 'رسم' }, + generating: lines => `جارٍ الإنشاء… ${lines} سطرًا`, + versionBadge: count => `${count} إصدارات`, + open: 'فتح' + }, + + artifactPane: { + tabFallback: 'ناتج', + modePreview: 'معاينة', + modeSource: 'المصدر', + versionOf: (current, total) => `الإصدار ${current} من ${total}`, + olderVersion: 'إصدار أقدم', + newerVersion: 'إصدار أحدث', + latest: 'الأحدث', + copyContent: 'نسخ المحتوى', + download: 'تنزيل', + openInBrowser: 'فتح في المتصفح', + openInBrowserFailed: 'تعذّر الفتح في المتصفح', + missingTitle: 'الناتج غير متاح', + missingBody: 'لم يعد هذا الناتج موجودًا في السجل المحلي.' + }, sidebar: { nav: { 'new-session': 'جلسة جديدة', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index d4005af11f7d..409faf98772a 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1757,6 +1757,29 @@ export const en: Translations = { copyPath: 'Copy path' }, + artifactCard: { + kind: { code: 'Code', html: 'Interactive page', svg: 'Graphic' }, + generating: lines => `Generating… ${lines} lines`, + versionBadge: count => `${count} versions`, + open: 'Open' + }, + + artifactPane: { + tabFallback: 'Artifact', + modePreview: 'PREVIEW', + modeSource: 'SOURCE', + versionOf: (current, total) => `v${current} of ${total}`, + olderVersion: 'Older version', + newerVersion: 'Newer version', + latest: 'Latest', + copyContent: 'Copy content', + download: 'Download', + openInBrowser: 'Open in browser', + openInBrowserFailed: 'Could not open in browser', + missingTitle: 'Artifact unavailable', + missingBody: 'This artifact is no longer in the local registry.' + }, + sidebar: { nav: { 'new-session': 'New session', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index a1eeea21d1a9..198058eedd68 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1621,6 +1621,29 @@ export const ja = defineLocale({ copyPath: 'パスをコピー' }, + artifactCard: { + kind: { code: 'コード', html: 'インタラクティブページ', svg: 'グラフィック' }, + generating: lines => `生成中… ${lines} 行`, + versionBadge: count => `${count} 個のバージョン`, + open: '開く' + }, + + artifactPane: { + tabFallback: 'アーティファクト', + modePreview: 'プレビュー', + modeSource: 'ソース', + versionOf: (current, total) => `${total} 中 v${current}`, + olderVersion: '前のバージョン', + newerVersion: '次のバージョン', + latest: '最新', + copyContent: 'コンテンツをコピー', + download: 'ダウンロード', + openInBrowser: 'ブラウザで開く', + openInBrowserFailed: 'ブラウザで開けませんでした', + missingTitle: 'アーティファクトを利用できません', + missingBody: 'このアーティファクトはローカルレジストリに存在しません。' + }, + sidebar: { nav: { 'new-session': '新しいセッション', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 217a66e9794a..e15db82546a1 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1466,6 +1466,29 @@ export interface Translations { copyPath: string } + artifactCard: { + kind: Record<'code' | 'html' | 'svg', string> + generating: (lines: number) => string + versionBadge: (count: number) => string + open: string + } + + artifactPane: { + tabFallback: string + modePreview: string + modeSource: string + versionOf: (current: number, total: number) => string + olderVersion: string + newerVersion: string + latest: string + copyContent: string + download: string + openInBrowser: string + openInBrowserFailed: string + missingTitle: string + missingBody: string + } + sidebar: { nav: Record searchAria: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index dfe9efe69868..12e7a5c2c59f 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1569,6 +1569,29 @@ export const zhHant = defineLocale({ copyPath: '複製路徑' }, + artifactCard: { + kind: { code: '程式碼', html: '互動頁面', svg: '圖形' }, + generating: lines => `產生中… ${lines} 行`, + versionBadge: count => `${count} 個版本`, + open: '開啟' + }, + + artifactPane: { + tabFallback: '產物', + modePreview: '預覽', + modeSource: '原始碼', + versionOf: (current, total) => `第 ${current}/${total} 版`, + olderVersion: '較舊版本', + newerVersion: '較新版本', + latest: '最新', + copyContent: '複製內容', + download: '下載', + openInBrowser: '在瀏覽器中開啟', + openInBrowserFailed: '無法在瀏覽器中開啟', + missingTitle: '產物無法使用', + missingBody: '此產物已不在本機註冊表中。' + }, + sidebar: { nav: { 'new-session': '新工作階段', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 518913dca952..71f033580aaf 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1947,6 +1947,29 @@ export const zh: Translations = { copyPath: '复制路径' }, + artifactCard: { + kind: { code: '代码', html: '交互页面', svg: '图形' }, + generating: lines => `生成中… ${lines} 行`, + versionBadge: count => `${count} 个版本`, + open: '打开' + }, + + artifactPane: { + tabFallback: '产物', + modePreview: '预览', + modeSource: '源码', + versionOf: (current, total) => `第 ${current}/${total} 版`, + olderVersion: '较旧版本', + newerVersion: '较新版本', + latest: '最新', + copyContent: '复制内容', + download: '下载', + openInBrowser: '在浏览器中打开', + openInBrowserFailed: '无法在浏览器中打开', + missingTitle: '产物不可用', + missingBody: '此产物已不在本地注册表中。' + }, + sidebar: { nav: { 'new-session': '新建会话', diff --git a/apps/desktop/src/lib/artifact-detect.test.ts b/apps/desktop/src/lib/artifact-detect.test.ts new file mode 100644 index 000000000000..d4aba791299b --- /dev/null +++ b/apps/desktop/src/lib/artifact-detect.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' + +import { artifactContentHash, artifactDownloadName, artifactSlug, detectArtifact } from './artifact-detect' + +const HTML_DOC = ` + +Pomodoro Timer + +

Pomodoro

+ + +` + +function longCode(lines: number): string { + return Array.from( + { length: lines }, + (_, i) => `export function helper${i}(value: number) { return value * ${i} }` + ).join('\n') +} + +describe('detectArtifact', () => { + it('promotes a full html document', () => { + const detection = detectArtifact('html', HTML_DOC) + + expect(detection).not.toBeNull() + expect(detection?.kind).toBe('html') + expect(detection?.title).toBe('Pomodoro Timer') + }) + + it('falls back to h1 when the document has no title tag', () => { + const doc = `

Budget Dashboard

${'
x
'.repeat(30)}` + + expect(detectArtifact('html', doc)?.title).toBe('Budget Dashboard') + }) + + it('ignores a small html snippet', () => { + expect(detectArtifact('html', '
hello
')).toBeNull() + }) + + it('ignores small svg fences (inline embed owns them)', () => { + expect(detectArtifact('svg', '')).toBeNull() + }) + + it('promotes a large svg', () => { + const svg = `Org Chart${''.repeat(80)}` + const detection = detectArtifact('svg', svg) + + expect(detection?.kind).toBe('svg') + expect(detection?.title).toBe('Org Chart') + }) + + it('keeps short code inline', () => { + expect(detectArtifact('python', 'print("hi")')).toBeNull() + }) + + it('promotes long code and derives a declaration title', () => { + const code = `export function buildDashboard(config: Config) {\n${longCode(60)}\n}` + const detection = detectArtifact('typescript', code) + + expect(detection?.kind).toBe('code') + expect(detection?.title).toBe('buildDashboard') + }) + + it('prefers a filename comment for the title', () => { + const code = `# server.py\n${longCode(60) + .replace(/export function/g, 'def') + .replace(/\{|\}/g, '')}` + + const detection = detectArtifact('python', code) + + expect(detection?.kind).toBe('code') + expect(detection?.title).toBe('server.py') + }) + + it('never promotes prose-ish or terminal fences', () => { + expect(detectArtifact('text', longCode(80))).toBeNull() + expect(detectArtifact('diff', longCode(80))).toBeNull() + expect(detectArtifact('markdown', longCode(80))).toBeNull() + expect(detectArtifact('mermaid', longCode(80))).toBeNull() + }) +}) + +describe('artifactSlug', () => { + it('is stable across regenerations of the same artifact', () => { + const a = artifactSlug({ kind: 'html', language: 'html', title: 'Pomodoro Timer' }) + const b = artifactSlug({ kind: 'html', language: 'html', title: 'Pomodoro Timer' }) + + expect(a).toBe(b) + expect(a).toContain('html') + }) + + it('distinguishes different titles', () => { + expect(artifactSlug({ kind: 'html', language: 'html', title: 'Timer' })).not.toBe( + artifactSlug({ kind: 'html', language: 'html', title: 'Dashboard' }) + ) + }) + + it('handles empty/symbol-only titles', () => { + expect(artifactSlug({ kind: 'code', language: 'ts', title: '!!!' })).toBe('code:ts:untitled') + }) +}) + +describe('artifactContentHash', () => { + it('is deterministic and content-sensitive', () => { + expect(artifactContentHash('abc')).toBe(artifactContentHash('abc')) + expect(artifactContentHash('abc')).not.toBe(artifactContentHash('abd')) + }) +}) + +describe('artifactDownloadName', () => { + it('keeps an existing extension', () => { + expect(artifactDownloadName('code', 'python', 'server.py')).toBe('server.py') + }) + + it('appends by kind and language', () => { + expect(artifactDownloadName('html', 'html', 'Pomodoro Timer')).toBe('Pomodoro-Timer.html') + expect(artifactDownloadName('svg', 'svg', 'Org Chart')).toBe('Org-Chart.svg') + expect(artifactDownloadName('code', 'typescript', 'buildDashboard')).toBe('buildDashboard.ts') + expect(artifactDownloadName('code', 'unknownlang', '')).toBe('artifact.txt') + }) +}) diff --git a/apps/desktop/src/lib/artifact-detect.ts b/apps/desktop/src/lib/artifact-detect.ts new file mode 100644 index 000000000000..464b92b6a9d4 --- /dev/null +++ b/apps/desktop/src/lib/artifact-detect.ts @@ -0,0 +1,232 @@ +import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code' + +/** + * Artifact detection — decides when a fenced block in an assistant message is + * substantial, self-contained content that deserves an artifact card (opening + * in the right rail) instead of an inline code block. + * + * Pure and cheap: it runs per streaming delta on the growing fence body, so + * everything here is a bounded regex scan or a line count. No store access. + */ + +export type ArtifactKind = 'code' | 'html' | 'svg' + +export interface ArtifactDetection { + kind: ArtifactKind + /** Sanitized fence language ('' possible for html/svg detected by shape). */ + language: string + /** Human title derived from the content (html , svg <title>, a named + * declaration for code). Falls back to a kind/language label. */ + title: string +} + +// A fence only becomes an artifact once it is unambiguously a document (html), +// a large standalone graphic (svg), or long enough that inlining it would +// drown the conversation (code). Small snippets stay inline code cards. +const HTML_DOC_RE = /<!doctype\s+html|<html[\s>]|<head[\s>]|<body[\s>]/i +const HTML_TAG_RE = /<[a-z][a-z0-9-]*(\s[^>]*)?>/i +const HTML_DOC_MIN_CHARS = 160 +const HTML_FRAGMENT_MIN_CHARS = 1200 +const SVG_MIN_CHARS = 2000 +const CODE_MIN_LINES = 48 +const CODE_MIN_CHARS = 3000 + +const HTML_LANGUAGES = new Set(['html', 'htm', 'xhtml']) + +// Languages whose fences are never artifacts: prose-ish, terminal output, and +// the fences already owned by richer renderers (mermaid diagrams, small svg). +const NON_ARTIFACT_LANGUAGES = new Set([ + '', + 'console', + 'diff', + 'log', + 'logs', + 'markdown', + 'md', + 'mermaid', + 'output', + 'patch', + 'plain', + 'plaintext', + 'shell-session', + 'stdout', + 'text', + 'txt' +]) + +function countLines(text: string): number { + let lines = 1 + let index = text.indexOf('\n') + + while (index !== -1) { + lines += 1 + index = text.indexOf('\n', index + 1) + } + + return lines +} + +function stripTags(value: string): string { + return value + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function titleFromTag(content: string, tag: 'h1' | 'title'): string { + const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i').exec(content) + + return match ? stripTags(match[1] || '').slice(0, 80) : '' +} + +const CODE_DECLARATION_RE = + /(?:^|\n)\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|struct|interface|enum|trait|impl|def|fn)\s+([A-Za-z_$][\w$]*)/ + +// `// app.py`, `# server.ts`, `<!-- index.html -->`, `/* main.rs */` on the +// first meaningful line — the de-facto LLM convention for naming a file. +const FILENAME_COMMENT_RE = /^\s*(?:\/\/|#|--|<!--|\/\*)\s*([\w./-]+\.[a-z0-9]{1,8})\b/i + +function codeTitle(language: string, content: string): string { + const head = content.slice(0, 2000) + const fileName = FILENAME_COMMENT_RE.exec(head)?.[1] + + if (fileName) { + return fileName + } + + const declaration = CODE_DECLARATION_RE.exec(head)?.[1] + + if (declaration) { + return declaration + } + + return language +} + +export function detectArtifact(language: string | undefined, code: string | undefined): ArtifactDetection | null { + const trimmed = (code ?? '').trim() + + if (!trimmed) { + return null + } + + const clean = sanitizeLanguageTag(language || '') + + if (HTML_LANGUAGES.has(clean)) { + const isDocument = HTML_DOC_RE.test(trimmed) + + if ( + (isDocument && trimmed.length >= HTML_DOC_MIN_CHARS) || + (!isDocument && trimmed.length >= HTML_FRAGMENT_MIN_CHARS && HTML_TAG_RE.test(trimmed)) + ) { + return { + kind: 'html', + language: clean, + title: titleFromTag(trimmed, 'title') || titleFromTag(trimmed, 'h1') || 'HTML' + } + } + + return null + } + + if (clean === 'svg') { + // Small svg fences keep their inline embed (svg-embed.tsx); only a large + // standalone graphic graduates into an artifact tab. + if (trimmed.length >= SVG_MIN_CHARS && /<svg[\s>]/i.test(trimmed)) { + return { kind: 'svg', language: clean, title: titleFromTag(trimmed, 'title') || 'SVG' } + } + + return null + } + + if (NON_ARTIFACT_LANGUAGES.has(clean)) { + return null + } + + if (trimmed.length < CODE_MIN_CHARS && countLines(trimmed) < CODE_MIN_LINES) { + return null + } + + if (isLikelyProseCodeBlock(clean, trimmed)) { + return null + } + + return { kind: 'code', language: clean, title: codeTitle(clean, trimmed) } +} + +/** Stable identity slug for versioning: the same (kind, title, language) in a + * session is treated as one artifact the model iterates on. */ +export function artifactSlug(detection: Pick<ArtifactDetection, 'kind' | 'language' | 'title'>): string { + const title = detection.title + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) + + return `${detection.kind}:${detection.language}:${title || 'untitled'}` +} + +/** Tiny non-cryptographic content hash (FNV-1a) for version dedupe. */ +export function artifactContentHash(content: string): string { + let hash = 0x811c9dc5 + + for (let i = 0; i < content.length; i += 1) { + hash ^= content.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + + return (hash >>> 0).toString(36) +} + +const DOWNLOAD_EXTENSION_BY_LANGUAGE: Record<string, string> = { + bash: '.sh', + c: '.c', + cpp: '.cpp', + csharp: '.cs', + css: '.css', + go: '.go', + htm: '.html', + html: '.html', + java: '.java', + javascript: '.js', + js: '.js', + json: '.json', + jsx: '.jsx', + kotlin: '.kt', + php: '.php', + py: '.py', + python: '.py', + rb: '.rb', + rs: '.rs', + ruby: '.rb', + rust: '.rs', + sh: '.sh', + sql: '.sql', + svg: '.svg', + swift: '.swift', + toml: '.toml', + ts: '.ts', + tsx: '.tsx', + typescript: '.ts', + xhtml: '.html', + xml: '.xml', + yaml: '.yaml', + yml: '.yaml' +} + +export function artifactDownloadName(kind: ArtifactKind, language: string, title: string): string { + const base = + title + .replace(/[^\p{L}\p{N}._ -]+/gu, '') + .trim() + .replace(/\s+/g, '-') + .slice(0, 60) || 'artifact' + + if (/\.[a-z0-9]{1,8}$/i.test(base)) { + return base + } + + const ext = kind === 'html' ? '.html' : kind === 'svg' ? '.svg' : DOWNLOAD_EXTENSION_BY_LANGUAGE[language] || '.txt' + + return `${base}${ext}` +} diff --git a/apps/desktop/src/lib/download-text.ts b/apps/desktop/src/lib/download-text.ts new file mode 100644 index 000000000000..3e9091fcd640 --- /dev/null +++ b/apps/desktop/src/lib/download-text.ts @@ -0,0 +1,16 @@ +/** Save generated text content to a file via a blob download. Electron's + * default will-download behavior shows the OS save dialog, so this works + * without a dedicated IPC handler (same pattern as use-image-download's + * browser fallback). */ +export function downloadTextFile(name: string, content: string, mimeType = 'text/plain') { + const blobUrl = URL.createObjectURL(new Blob([content], { type: `${mimeType};charset=utf-8` })) + const link = document.createElement('a') + + link.href = blobUrl + link.download = name + link.rel = 'noopener noreferrer' + document.body.appendChild(link) + link.click() + link.remove() + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000) +} diff --git a/apps/desktop/src/store/artifacts.test.ts b/apps/desktop/src/store/artifacts.test.ts new file mode 100644 index 000000000000..aacbeb042a50 --- /dev/null +++ b/apps/desktop/src/store/artifacts.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import type { ArtifactDetection } from '@/lib/artifact-detect' + +import { + $artifactRegistry, + $artifactTabs, + $artifactVersionSelection, + artifactsForSession, + artifactTabId, + clearArtifactRegistry, + closeArtifactTab, + getArtifact, + openArtifactTab, + selectArtifactVersion, + upsertArtifact +} from './artifacts' +import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout' +import { $paneOpen } from './panes' +import { $activeSessionId, $selectedStoredSessionId } from './session' + +const HTML_DETECTION: ArtifactDetection = { kind: 'html', language: 'html', title: 'Pomodoro Timer' } + +describe('artifacts store', () => { + beforeEach(() => { + $activeSessionId.set('session-1') + $selectedStoredSessionId.set(null) + window.localStorage.clear() + clearArtifactRegistry() + $rightRailActiveTabId.set(RIGHT_RAIL_PREVIEW_TAB_ID) + }) + + afterEach(() => { + $activeSessionId.set(null) + $selectedStoredSessionId.set(null) + clearArtifactRegistry() + window.localStorage.clear() + }) + + it('registers a new artifact with one version', () => { + const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>') + + expect(result?.versionAdded).toBe(true) + expect(artifactsForSession('session-1')).toHaveLength(1) + expect(getArtifact(result!.artifactId)?.versions).toHaveLength(1) + }) + + it('dedupes identical content by hash (streaming replays are no-ops)', () => { + const first = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>') + const replay = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>') + + expect(replay?.versionAdded).toBe(false) + expect(replay?.artifactId).toBe(first?.artifactId) + expect(getArtifact(first!.artifactId)?.versions).toHaveLength(1) + }) + + it('appends a version when the same artifact regenerates with new content', () => { + const first = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>') + const second = upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>') + + expect(second?.versionAdded).toBe(true) + expect(second?.artifactId).toBe(first?.artifactId) + + const record = getArtifact(first!.artifactId) + + expect(record?.versions).toHaveLength(2) + expect(record?.versions.at(-1)?.content).toBe('<html>v2</html>') + expect(artifactsForSession('session-1')).toHaveLength(1) + }) + + it('keeps different titles as separate artifacts', () => { + upsertArtifact('session-1', HTML_DETECTION, '<html>timer</html>') + upsertArtifact('session-1', { ...HTML_DETECTION, title: 'Budget Dashboard' }, '<html>budget</html>') + + expect(artifactsForSession('session-1')).toHaveLength(2) + }) + + it('scopes artifacts per session', () => { + upsertArtifact('session-1', HTML_DETECTION, '<html>a</html>') + upsertArtifact('session-2', HTML_DETECTION, '<html>b</html>') + + expect(artifactsForSession('session-1')).toHaveLength(1) + expect(artifactsForSession('session-2')).toHaveLength(1) + }) + + it('persists the registry to localStorage', () => { + upsertArtifact('session-1', HTML_DETECTION, '<html>persisted</html>') + + expect(window.localStorage.getItem('hermes.desktop.artifacts.v1')).toContain('persisted') + }) + + it('rejects empty sessions and empty content', () => { + expect(upsertArtifact('', HTML_DETECTION, '<html>x</html>')).toBeNull() + expect(upsertArtifact('session-1', HTML_DETECTION, ' ')).toBeNull() + }) + + it('opens and closes artifact tabs, driving the rail selection', () => { + const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')! + const tabId = artifactTabId(result.artifactId) + + openArtifactTab(result.artifactId) + + expect($artifactTabs.get()).toEqual([tabId]) + expect($rightRailActiveTabId.get()).toBe(tabId) + expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(true) + + closeArtifactTab(tabId) + + expect($artifactTabs.get()).toEqual([]) + expect($rightRailActiveTabId.get()).toBe(RIGHT_RAIL_PREVIEW_TAB_ID) + }) + + it('does not duplicate a tab when the same artifact opens twice', () => { + const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')! + + openArtifactTab(result.artifactId) + openArtifactTab(result.artifactId) + + expect($artifactTabs.get()).toHaveLength(1) + }) + + it('tracks version selection and snaps back to latest', () => { + const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')! + + upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>') + upsertArtifact('session-1', HTML_DETECTION, '<html>v3</html>') + + selectArtifactVersion(result.artifactId, 0) + + expect($artifactVersionSelection.get()[result.artifactId]).toBe(0) + + // Selecting the newest version clears the pin (absent = newest). + selectArtifactVersion(result.artifactId, 2) + + expect(result.artifactId in $artifactVersionSelection.get()).toBe(false) + + // Out-of-range clamps. + selectArtifactVersion(result.artifactId, -5) + + expect($artifactVersionSelection.get()[result.artifactId]).toBe(0) + }) + + it('reopening an artifact lands on the newest version', () => { + const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')! + + upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>') + selectArtifactVersion(result.artifactId, 0) + openArtifactTab(result.artifactId) + + expect(result.artifactId in $artifactVersionSelection.get()).toBe(false) + }) + + it('survives a registry reload round-trip', () => { + upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>') + upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>') + + const raw = window.localStorage.getItem('hermes.desktop.artifacts.v1')! + const parsed = JSON.parse(raw) as Record<string, unknown[]> + + expect(parsed['session-1']).toHaveLength(1) + + // Simulate a fresh boot: hydrate a clean registry from the persisted JSON. + $artifactRegistry.set(JSON.parse(raw)) + + const record = artifactsForSession('session-1')[0] + + expect(record?.versions).toHaveLength(2) + expect(record?.versions.at(-1)?.content).toBe('<html>v2</html>') + }) +}) diff --git a/apps/desktop/src/store/artifacts.ts b/apps/desktop/src/store/artifacts.ts new file mode 100644 index 000000000000..b1d7025f1a00 --- /dev/null +++ b/apps/desktop/src/store/artifacts.ts @@ -0,0 +1,386 @@ +import { atom, computed } from 'nanostores' + +import { artifactContentHash, type ArtifactDetection, type ArtifactKind, artifactSlug } from '@/lib/artifact-detect' +import { persistentAtom } from '@/lib/persisted' + +import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID, selectRightRailTab } from './layout' +import { setPaneOpen } from './panes' +import { $activeSessionId, $selectedStoredSessionId } from './session' + +/** + * ARTIFACT REGISTRY — substantial generated content (HTML pages, large SVGs, + * long code) produced in the transcript, promoted out of the message flow into + * versioned, openable artifacts. The renderer owns this state: artifacts are a + * presentation of message content the backend already persists, so the store + * is a cache keyed by session with bounded history. + * + * Identity: one artifact = one (session, slug) pair, where the slug derives + * from kind + language + title. When the model regenerates "the dashboard" + * three times in a session, that is ONE artifact with three versions, exactly + * like a document the user keeps refining — not three cards. + */ + +export interface ArtifactVersion { + content: string + createdAt: number + hash: string +} + +export interface ArtifactRecord { + createdAt: number + id: string + kind: ArtifactKind + language: string + sessionId: string + slug: string + title: string + updatedAt: number + /** Oldest → newest. The last entry is the current version. */ + versions: ArtifactVersion[] +} + +type ArtifactRegistry = Record<string, ArtifactRecord[]> + +const STORAGE_KEY = 'hermes.desktop.artifacts.v1' +const MAX_ARTIFACTS_PER_SESSION = 24 +const MAX_VERSIONS_PER_ARTIFACT = 20 +const MAX_SESSIONS = 40 +// localStorage is ~5MB; artifacts carry full content, so cap the persisted +// bytes per artifact aggressively. Oversized artifacts survive in memory for +// the app's lifetime but persist only their newest version(s) that fit. +const MAX_PERSISTED_CHARS_PER_ARTIFACT = 120_000 + +export type ArtifactTabId = `artifact:${string}` + +export function artifactTabId(artifactId: string): ArtifactTabId { + return `artifact:${artifactId}` +} + +export function artifactIdFromTabId(tabId: string): string | null { + return tabId.startsWith('artifact:') ? tabId.slice('artifact:'.length) : null +} + +function isArtifactVersion(value: unknown): value is ArtifactVersion { + if (!value || typeof value !== 'object') { + return false + } + + const r = value as Record<string, unknown> + + return typeof r.content === 'string' && typeof r.createdAt === 'number' && typeof r.hash === 'string' +} + +function isArtifactRecord(value: unknown): value is ArtifactRecord { + if (!value || typeof value !== 'object') { + return false + } + + const r = value as Record<string, unknown> + + return ( + typeof r.createdAt === 'number' && + typeof r.id === 'string' && + (r.kind === 'code' || r.kind === 'html' || r.kind === 'svg') && + typeof r.language === 'string' && + typeof r.sessionId === 'string' && + typeof r.slug === 'string' && + typeof r.title === 'string' && + typeof r.updatedAt === 'number' && + Array.isArray(r.versions) && + r.versions.length > 0 && + r.versions.every(isArtifactVersion) + ) +} + +function sanitizeRegistry(value: unknown): ArtifactRegistry { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + + const out: ArtifactRegistry = {} + + for (const [sessionId, records] of Object.entries(value as Record<string, unknown>)) { + if (!Array.isArray(records)) { + continue + } + + const valid = records.filter(isArtifactRecord) + + if (valid.length > 0) { + out[sessionId] = valid + } + } + + return out +} + +function persistedVersions(record: ArtifactRecord): ArtifactVersion[] { + const kept: ArtifactVersion[] = [] + let budget = MAX_PERSISTED_CHARS_PER_ARTIFACT + + // Newest first; always keep at least the current version even if oversized. + for (let i = record.versions.length - 1; i >= 0; i -= 1) { + const version = record.versions[i]! + + if (kept.length > 0 && version.content.length > budget) { + break + } + + budget -= version.content.length + kept.unshift(version) + } + + return kept +} + +function pruneRegistry(registry: ArtifactRegistry): ArtifactRegistry { + const entries = Object.entries(registry) + .map(([sessionId, records]) => { + const trimmed = [...records] + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, MAX_ARTIFACTS_PER_SESSION) + .sort((a, b) => a.createdAt - b.createdAt) + + return [sessionId, trimmed] as const + }) + .filter(([, records]) => records.length > 0) + .sort(([, a], [, b]) => { + const latest = (records: readonly ArtifactRecord[]) => Math.max(...records.map(record => record.updatedAt)) + + return latest(b) - latest(a) + }) + .slice(0, MAX_SESSIONS) + + return Object.fromEntries(entries) +} + +export const $artifactRegistry = persistentAtom<ArtifactRegistry>( + STORAGE_KEY, + {}, + { + decode: raw => sanitizeRegistry(JSON.parse(raw) as unknown), + encode: registry => + JSON.stringify( + Object.fromEntries( + Object.entries(pruneRegistry(registry)).map(([sessionId, records]) => [ + sessionId, + records.map(record => ({ ...record, versions: persistedVersions(record) })) + ]) + ) + ) + } +) + +/** Artifact tabs open in the right rail (ids into the registry). */ +export const $artifactTabs = atom<ArtifactTabId[]>([]) + +/** Per-tab selected version index; absent = newest. Ephemeral by design: a + * reopened artifact always lands on its current version. */ +export const $artifactVersionSelection = atom<Record<string, number>>({}) + +function currentArtifactSessionId(): string { + return $selectedStoredSessionId.get() || $activeSessionId.get() || '' +} + +export function getArtifact(artifactId: string): ArtifactRecord | null { + for (const records of Object.values($artifactRegistry.get())) { + const found = records.find(record => record.id === artifactId) + + if (found) { + return found + } + } + + return null +} + +export const $openArtifacts = computed([$artifactRegistry, $artifactTabs], (registry, tabs) => { + const byId = new Map<string, ArtifactRecord>() + + for (const records of Object.values(registry)) { + for (const record of records) { + byId.set(record.id, record) + } + } + + return tabs + .map(tabId => { + const id = artifactIdFromTabId(tabId) + const record = id ? byId.get(id) : undefined + + return record ? { record, tabId } : null + }) + .filter((entry): entry is { record: ArtifactRecord; tabId: ArtifactTabId } => entry !== null) +}) + +export function artifactsForSession(sessionId: string | null | undefined): ArtifactRecord[] { + const id = sessionId?.trim() + + if (!id) { + return [] + } + + return $artifactRegistry.get()[id] ?? [] +} + +interface UpsertResult { + artifactId: string + record: ArtifactRecord + /** True when this call appended a NEW version (vs. deduped/no-op). */ + versionAdded: boolean +} + +/** + * Register (or version) an artifact for a session. Same slug + same content + * hash is a no-op (streaming remounts and transcript re-renders call this + * repeatedly); same slug + new content appends a version. + */ +export function upsertArtifact( + sessionId: string | null | undefined, + detection: ArtifactDetection, + content: string +): UpsertResult | null { + const id = sessionId?.trim() + const trimmed = content.trim() + + if (!id || !trimmed) { + return null + } + + const slug = artifactSlug(detection) + const hash = artifactContentHash(trimmed) + const registry = $artifactRegistry.get() + const records = registry[id] ?? [] + const existing = records.find(record => record.slug === slug) + const now = Date.now() + + if (existing) { + const known = existing.versions.some(version => version.hash === hash) + + if (known) { + return { artifactId: existing.id, record: existing, versionAdded: false } + } + + const versions = [...existing.versions, { content: trimmed, createdAt: now, hash }].slice( + -MAX_VERSIONS_PER_ARTIFACT + ) + + const next: ArtifactRecord = { + ...existing, + // A regenerated artifact may carry a sharper title (html <title> arrives + // late in the stream); prefer the newest non-generic one. + title: detection.title || existing.title, + updatedAt: now, + versions + } + + $artifactRegistry.set( + pruneRegistry({ + ...registry, + [id]: records.map(record => (record.id === existing.id ? next : record)) + }) + ) + + return { artifactId: existing.id, record: next, versionAdded: true } + } + + const record: ArtifactRecord = { + createdAt: now, + id: `${id}:${slug}`, + kind: detection.kind, + language: detection.language, + sessionId: id, + slug, + title: detection.title, + updatedAt: now, + versions: [{ content: trimmed, createdAt: now, hash }] + } + + $artifactRegistry.set(pruneRegistry({ ...registry, [id]: [...records, record] })) + + return { artifactId: record.id, record, versionAdded: true } +} + +export function upsertCurrentSessionArtifact(detection: ArtifactDetection, content: string): UpsertResult | null { + return upsertArtifact(currentArtifactSessionId(), detection, content) +} + +/** Open an artifact tab in the right rail and select it. User-initiated only + * (card click) — never called from streaming, per the no-hijack rule. */ +export function openArtifactTab(artifactId: string) { + const tabId = artifactTabId(artifactId) + const current = $artifactTabs.get() + + if (!current.includes(tabId)) { + $artifactTabs.set([...current, tabId]) + } + + // Land on the newest version whenever (re)opened. + const selection = $artifactVersionSelection.get() + + if (artifactId in selection) { + const { [artifactId]: _dropped, ...rest } = selection + $artifactVersionSelection.set(rest) + } + + setPaneOpen(PREVIEW_PANE_ID, true) + selectRightRailTab(tabId) +} + +export function closeArtifactTab(tabId: ArtifactTabId): boolean { + const current = $artifactTabs.get() + const index = current.indexOf(tabId) + + if (index === -1) { + return false + } + + const next = current.filter(id => id !== tabId) + + $artifactTabs.set(next) + + const artifactId = artifactIdFromTabId(tabId) + + if (artifactId) { + const { [artifactId]: _dropped, ...rest } = $artifactVersionSelection.get() + $artifactVersionSelection.set(rest) + } + + if ($rightRailActiveTabId.get() === tabId) { + selectRightRailTab(next[Math.min(index, next.length - 1)] ?? RIGHT_RAIL_PREVIEW_TAB_ID) + } + + return true +} + +export function selectArtifactVersion(artifactId: string, versionIndex: number) { + const record = getArtifact(artifactId) + + if (!record) { + return + } + + const clamped = Math.max(0, Math.min(record.versions.length - 1, versionIndex)) + const selection = $artifactVersionSelection.get() + + if (clamped === record.versions.length - 1) { + if (artifactId in selection) { + const { [artifactId]: _dropped, ...rest } = selection + $artifactVersionSelection.set(rest) + } + + return + } + + $artifactVersionSelection.set({ ...selection, [artifactId]: clamped }) +} + +export function closeAllArtifactTabs() { + $artifactTabs.set([]) + $artifactVersionSelection.set({}) +} + +export function clearArtifactRegistry() { + $artifactRegistry.set({}) + closeAllArtifactTabs() +} diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts index 507ade879c00..a551e2e1ed0e 100644 --- a/apps/desktop/src/store/gateway-switch.ts +++ b/apps/desktop/src/store/gateway-switch.ts @@ -3,6 +3,7 @@ import { atom } from 'nanostores' import { resetLiveRuntimeTracking } from '@/app/contrib/hooks/use-background-sync' import { resetSidebarBatchCapability } from '@/hermes' import { invalidateProfileScopedQueries } from '@/lib/query-client' +import { closeAllArtifactTabs } from '@/store/artifacts' import { resetSessionsLimit } from '@/store/layout' import { $unreadFinishedSessionIds, @@ -61,6 +62,10 @@ export function wipeSessionListsForGatewaySwitch(): void { setMessages([]) setFreshDraftReady(true) + // Artifact tabs reference sessions on the previous backend; the registry + // itself survives (it's local presentation state) but open tabs must not. + closeAllArtifactTabs() + // Narrowed: account/marketplace/onboarding caches are global, not gateway- // scoped, so a mode swap must not refetch them. invalidateProfileScopedQueries() diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index f329e965265b..6e08c88a2e57 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -40,7 +40,7 @@ export const FILE_BROWSER_PANE_ID = 'file-browser' export const PREVIEW_PANE_ID = 'preview' export const RIGHT_RAIL_PREVIEW_TAB_ID = 'preview' -export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `file:${string}` +export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `artifact:${string}` | `file:${string}` ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true }) ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false }) diff --git a/apps/desktop/src/store/preview.ts b/apps/desktop/src/store/preview.ts index 678bcd1f3f7d..2454698b6229 100644 --- a/apps/desktop/src/store/preview.ts +++ b/apps/desktop/src/store/preview.ts @@ -3,6 +3,7 @@ import { atom, computed } from 'nanostores' import { persistentAtom } from '@/lib/persisted' import { normalize } from '@/lib/text' +import { $artifactTabs, type ArtifactTabId, closeAllArtifactTabs, closeArtifactTab } from './artifacts' import { $rightRailActiveTabId, PREVIEW_PANE_ID, @@ -79,10 +80,12 @@ export const $filePreviewTabs = persistentAtom<FilePreviewTab[]>(TABS_STORAGE_KE }) // Drop a restored active file-tab that didn't survive validation so the rail -// never points at a tab that isn't there. +// never points at a tab that isn't there. Artifact tabs are ephemeral (never +// restored), so a persisted `artifact:` active id is always stale too. if ( - $rightRailActiveTabId.get().startsWith('file:') && - !$filePreviewTabs.get().some(tab => tab.id === $rightRailActiveTabId.get()) + ($rightRailActiveTabId.get().startsWith('file:') && + !$filePreviewTabs.get().some(tab => tab.id === $rightRailActiveTabId.get())) || + $rightRailActiveTabId.get().startsWith('artifact:') ) { selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID) } @@ -424,10 +427,10 @@ export function dismissPreviewTarget() { $previewTarget.set(null) if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID) { - selectRightRailTab($filePreviewTabs.get()[0]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID) + selectRightRailTab($filePreviewTabs.get()[0]?.id ?? $artifactTabs.get()[0] ?? RIGHT_RAIL_PREVIEW_TAB_ID) } - setPaneOpen(PREVIEW_PANE_ID, $filePreviewTabs.get().length > 0) + setPaneOpen(PREVIEW_PANE_ID, $filePreviewTabs.get().length > 0 || $artifactTabs.get().length > 0) } function closeFilePreviewTab(tabId: RightRailTabId) { @@ -447,10 +450,12 @@ function closeFilePreviewTab(tabId: RightRailTabId) { $filePreviewTabs.set(next) if ($rightRailActiveTabId.get() === tabId) { - selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID) + selectRightRailTab( + next[Math.min(index, next.length - 1)]?.id ?? $artifactTabs.get()[0] ?? RIGHT_RAIL_PREVIEW_TAB_ID + ) } - if (next.length === 0 && !$previewTarget.get()) { + if (next.length === 0 && !$previewTarget.get() && $artifactTabs.get().length === 0) { setPaneOpen(PREVIEW_PANE_ID, false) } } @@ -464,6 +469,16 @@ export function closeRightRailTab(tabId: RightRailTabId) { return } + if (tabId.startsWith('artifact:')) { + closeArtifactTab(tabId as ArtifactTabId) + + if (!$previewTarget.get() && $filePreviewTabs.get().length === 0 && $artifactTabs.get().length === 0) { + setPaneOpen(PREVIEW_PANE_ID, false) + } + + return + } + closeFilePreviewTab(tabId) } @@ -474,7 +489,7 @@ export function closeActiveRightRailTab(): boolean { let tabId = $rightRailActiveTabId.get() if (tabId === RIGHT_RAIL_PREVIEW_TAB_ID && !$previewTarget.get()) { - const fallback = $filePreviewTabs.get()[0]?.id + const fallback = $filePreviewTabs.get()[0]?.id ?? $artifactTabs.get()[0] if (!fallback) { return false @@ -493,6 +508,16 @@ export function closeActiveRightRailTab(): boolean { return true } + if (tabId.startsWith('artifact:')) { + if (!$artifactTabs.get().includes(tabId as ArtifactTabId)) { + return false + } + + closeRightRailTab(tabId) + + return true + } + if (!$filePreviewTabs.get().some(tab => tab.id === tabId)) { return false } @@ -503,7 +528,7 @@ export function closeActiveRightRailTab(): boolean { } // The rail's visible tab order: the live preview tab (when present) first, then -// the file tabs in their stored order. Mirrors `ChatPreviewRail`'s `tabs` memo +// the file tabs, then artifact tabs. Mirrors `ChatPreviewRail`'s `tabs` memo // so "close others / to the right" act on what the user actually sees. function rightRailTabOrder(): RightRailTabId[] { const ids: RightRailTabId[] = [] @@ -516,6 +541,10 @@ function rightRailTabOrder(): RightRailTabId[] { ids.push(tab.id) } + for (const tabId of $artifactTabs.get()) { + ids.push(tabId) + } + return ids } @@ -544,13 +573,14 @@ export function closeRightRailTabsToRight(tabId: RightRailTabId) { } } -/** Dismisses the active preview + every file tab so the rail pane unmounts. */ +/** Dismisses the active preview + every file and artifact tab so the rail pane unmounts. */ export function closeRightRail() { if ($previewTarget.get()) { dismissPreviewTarget() } $filePreviewTabs.set([]) + closeAllArtifactTabs() setPaneOpen(PREVIEW_PANE_ID, false) } From a94ec27f970b5f30859ecec6b1c9c60619fe7ad0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Sun, 26 Jul 2026 22:00:29 -0500 Subject: [PATCH 02/71] polish(desktop): tokenize artifact card font sizes, drop redundant hover class - artifact card title/meta/open-hint now use --conversation-text-font-size / --conversation-tool-font-size like CodeCard and the rest of the transcript, instead of hardcoded rem literals - drop no-op hover:border-border (border is border-border at rest) - comment the deliberate raw bg-white + colorScheme:light on the sandboxed iframe so it doesn't read as an untokenized literal --- .../src/app/chat/right-rail/artifact-renderers.tsx | 3 +++ .../desktop/src/components/assistant-ui/artifact-card.tsx | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx b/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx index 61b49809265a..00ed9a58a58b 100644 --- a/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx +++ b/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx @@ -97,6 +97,9 @@ export function ArtifactLivePreview({ content, kind, title }: { content: string; className="block size-full border-0 bg-white" sandbox="allow-scripts" srcDoc={composeArtifactHtml(content)} + // Deliberately raw white + forced light scheme: the frame hosts foreign + // generated HTML that assumes a light canvas, so it renders deterministically + // light in both app themes instead of inheriting theme tokens it can't see. style={{ colorScheme: 'light' }} title={title} /> diff --git a/apps/desktop/src/components/assistant-ui/artifact-card.tsx b/apps/desktop/src/components/assistant-ui/artifact-card.tsx index 7444c23c05f0..56e53e031296 100644 --- a/apps/desktop/src/components/assistant-ui/artifact-card.tsx +++ b/apps/desktop/src/components/assistant-ui/artifact-card.tsx @@ -108,7 +108,7 @@ export function ArtifactCard({ code, detection, streaming = false }: ArtifactCar <button className={cn( 'group/artifact my-1.5 flex w-full max-w-md items-center gap-2.5 overflow-hidden rounded-[0.625rem] border border-border px-3 py-2.5 text-left transition-colors', - streaming ? 'cursor-default' : 'cursor-pointer hover:border-border hover:bg-accent/40' + streaming ? 'cursor-default' : 'cursor-pointer hover:bg-accent/40' )} data-slot="aui_artifact-card" disabled={streaming} @@ -121,13 +121,13 @@ export function ArtifactCard({ code, detection, streaming = false }: ArtifactCar <span className="min-w-0 flex-1"> <span className={cn( - 'block truncate text-[0.8125rem] font-medium text-foreground', + 'block truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground', streaming && 'shimmer text-foreground/55' )} > {title} </span> - <span className="block truncate text-[0.6875rem] text-muted-foreground"> + <span className="block truncate text-[length:var(--conversation-tool-font-size)] text-muted-foreground"> {streaming ? copy.generating(lineCount) : versionCount > 1 @@ -136,7 +136,7 @@ export function ArtifactCard({ code, detection, streaming = false }: ArtifactCar </span> </span> {!streaming && ( - <span className="shrink-0 text-[0.6875rem] font-medium text-muted-foreground opacity-0 transition-opacity group-hover/artifact:opacity-100"> + <span className="shrink-0 text-[length:var(--conversation-tool-font-size)] font-medium text-muted-foreground opacity-0 transition-opacity group-hover/artifact:opacity-100"> {copy.open} </span> )} From e369d6ea3f8ded58012618ac55300cdb1fc24b8d Mon Sep 17 00:00:00 2001 From: embwl0x <embwl0x@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:36:03 -0400 Subject: [PATCH 03/71] feat(delegate): expose redacted child tool history --- agent/shell_hooks.py | 1 + docs/observability/README.md | 6 +- hermes_cli/hooks.py | 12 +++ tests/agent/test_subagent_stop_hook.py | 88 +++++++++++++++- tests/tools/test_delegate.py | 4 + tools/delegate_tool.py | 123 +++++++++++++++++++++- website/docs/user-guide/features/hooks.md | 3 +- 7 files changed, 233 insertions(+), 4 deletions(-) diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index 80453bc37139..5699e4d9eb65 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -100,6 +100,7 @@ emitted by each built-in hook site. child_role – role string of the child agent child_summary – summary of the child's work child_status – exit status string (e.g. "success", "error") + tool_call_history – redacted tool name/input summary/byte counts/status list duration_ms – wall-clock time of the child run in milliseconds """ diff --git a/docs/observability/README.md b/docs/observability/README.md index 9040929ca489..800c7d73e958 100644 --- a/docs/observability/README.md +++ b/docs/observability/README.md @@ -217,7 +217,11 @@ Subagent hooks describe delegated child-agent work: and `child_goal`. `subagent_stop` fields include parent/child session IDs, role/status fields, -`child_summary`, and `duration_ms`. +`child_summary`, `duration_ms`, and a metadata-only `tool_call_history`. Each +history entry contains the tool name, argument names, bounded side-effect +targets, input/output byte counts, and outcome. URL query strings and fragments +are removed; raw arguments, prompts, commands, contents, headers, and results +are intentionally excluded. Observers can use these hooks to model nested trajectories while keeping child agent execution linked to the parent turn that spawned it. diff --git a/hermes_cli/hooks.py b/hermes_cli/hooks.py index d3f86bd00e80..58faf7b41df5 100644 --- a/hermes_cli/hooks.py +++ b/hermes_cli/hooks.py @@ -189,6 +189,18 @@ _DEFAULT_PAYLOADS = { "child_role": None, "child_summary": "Synthetic summary for hooks test", "child_status": "completed", + "tool_call_history": [ + { + "tool_name": "write_file", + "tool_input": { + "argument_keys": ["content", "path"], + "targets": {"path": "/tmp/report.txt"}, + }, + "input_bytes": 128, + "output_bytes": 32, + "status": "ok", + } + ], "duration_ms": 1234, }, } diff --git a/tests/agent/test_subagent_stop_hook.py b/tests/agent/test_subagent_stop_hook.py index a2b417a0721f..84c0bbe4127b 100644 --- a/tests/agent/test_subagent_stop_hook.py +++ b/tests/agent/test_subagent_stop_hook.py @@ -5,6 +5,7 @@ Covers wire-up from tools.delegate_tool.delegate_task: * runs on the parent thread (no re-entrancy for hook authors) * carries child_role when the agent exposes _delegate_role * carries child_role=None when _delegate_role is not set (pre-M3) + * exposes a detached, metadata-only tool_call_history """ from __future__ import annotations @@ -15,7 +16,7 @@ from unittest.mock import MagicMock, patch import pytest -from tools.delegate_tool import delegate_task +from tools.delegate_tool import _summarize_tool_arguments, delegate_task from hermes_cli import plugins @@ -193,6 +194,91 @@ class TestBatchMode: class TestPayloadShape: + def test_includes_redacted_tool_call_history(self): + captured = _register_capturing_hook() + + with patch("tools.delegate_tool._run_single_child") as mock_run: + mock_run.return_value = { + "task_index": 0, + "status": "completed", + "summary": "wrote the report", + "api_calls": 1, + "duration_seconds": 0.1, + "tool_trace": [{ + "tool": "write_file", + "args_bytes": 128, + "result_bytes": 32, + "status": "ok", + "input_summary": { + "argument_keys": ["content", "path", "token"], + "targets": { + "path": "/private/report.json", + "token": "must-not-leak", + }, + }, + "args": {"path": "/private/report.json"}, + "result": "secret output", + }], + } + delegate_task(goal="do X", parent_agent=_make_parent()) + + assert captured[0]["tool_call_history"] == [{ + "tool_name": "write_file", + "tool_input": { + "argument_keys": ["content", "path", "token"], + "targets": {"path": "/private/report.json"}, + }, + "input_bytes": 128, + "output_bytes": 32, + "status": "ok", + }] + + def test_tool_input_summary_keeps_targets_not_payloads(self): + summary = _summarize_tool_arguments(json.dumps({ + "path": "/workspace/report.json", + "content": "private report contents", + "url": "https://example.test/upload?token=secret#fragment", + "command": "curl -H 'Authorization: secret' example.test", + })) + + assert summary == { + "argument_keys": ["command", "content", "path", "url"], + "targets": { + "path": "/workspace/report.json", + "url": "https://example.test/upload", + }, + } + + def test_tool_call_history_is_detached_from_delegate_result(self): + def _mutating_hook(**kwargs): + kwargs["tool_call_history"][0]["status"] = "hook-mutated" + + plugins.get_plugin_manager()._hooks.setdefault( + "subagent_stop", [] + ).append(_mutating_hook) + + with patch("tools.delegate_tool._run_single_child") as mock_run: + mock_run.return_value = { + "task_index": 0, + "status": "completed", + "summary": "done", + "api_calls": 1, + "duration_seconds": 0.1, + "tool_trace": [{ + "tool": "terminal", + "args_bytes": 10, + "result_bytes": 20, + "status": "ok", + "input_summary": { + "argument_keys": ["command"], + "targets": {}, + }, + }], + } + raw = delegate_task(goal="do X", parent_agent=_make_parent()) + + assert json.loads(raw)["results"][0]["tool_trace"][0]["status"] == "ok" + def test_role_absent_becomes_none(self): captured = _register_capturing_hook() diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index e17a0c77e24f..48d5a629d871 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -784,6 +784,10 @@ class TestDelegateObservability(unittest.TestCase): self.assertEqual(entry["tool_trace"][0]["tool"], "web_search") self.assertIn("args_bytes", entry["tool_trace"][0]) self.assertIn("result_bytes", entry["tool_trace"][0]) + self.assertEqual( + entry["tool_trace"][0]["input_summary"], + {"argument_keys": ["query"], "targets": {}}, + ) self.assertEqual(entry["tool_trace"][0]["status"], "ok") def test_tool_trace_handles_list_content_blocks(self): diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 50f58c1a0d2c..c7dba38ea828 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -30,6 +30,7 @@ from concurrent.futures import ( TimeoutError as FuturesTimeoutError, ) from typing import Any, Dict, List, Optional +from urllib.parse import urlsplit, urlunsplit from toolsets import TOOLSETS @@ -298,6 +299,121 @@ def _stringify_tool_content(content: Any) -> str: return str(content) +_TOOL_INPUT_TARGET_KEYS = frozenset({ + "cwd", + "destination_path", + "directory", + "dst", + "endpoint", + "file_path", + "new_path", + "old_path", + "path", + "source_path", + "src", + "target_path", + "url", + "urls", +}) +_TOOL_INPUT_URL_KEYS = frozenset({"endpoint", "url", "urls"}) + + +def _sanitize_tool_target(key: str, value: Any) -> Any: + """Keep bounded side-effect targets while dropping URL secrets.""" + if isinstance(value, list): + cleaned = [ + item for item in (_sanitize_tool_target(key, item) for item in value[:16]) + if item is not None + ] + return cleaned or None + if not isinstance(value, str) or not value: + return None + bounded = value[:1024] + if key in _TOOL_INPUT_URL_KEYS: + try: + parsed = urlsplit(bounded) + if parsed.scheme and parsed.netloc: + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) + except ValueError: + return None + return bounded + + +def _summarize_tool_arguments(arguments: Any) -> Dict[str, Any]: + """Summarize argument names and side-effect targets without raw payloads.""" + if not isinstance(arguments, str): + return {"argument_keys": [], "targets": {}} + try: + parsed = json.loads(arguments) + except (TypeError, ValueError): + return {"argument_keys": [], "targets": {}} + if not isinstance(parsed, dict): + return {"argument_keys": [], "targets": {}} + + keys = sorted(str(key)[:128] for key in parsed)[:64] + targets: Dict[str, Any] = {} + for raw_key, value in parsed.items(): + key = str(raw_key).lower() + if key not in _TOOL_INPUT_TARGET_KEYS: + continue + cleaned = _sanitize_tool_target(key, value) + if cleaned is not None: + targets[key] = cleaned + return {"argument_keys": keys, "targets": targets} + + +def _sanitize_tool_input_summary(summary: Any) -> Dict[str, Any]: + if not isinstance(summary, dict): + return {"argument_keys": [], "targets": {}} + keys = summary.get("argument_keys") + safe_keys = ( + [str(key)[:128] for key in keys[:64]] + if isinstance(keys, list) + else [] + ) + targets = summary.get("targets") + safe_targets: Dict[str, Any] = {} + if isinstance(targets, dict): + for raw_key, value in targets.items(): + key = str(raw_key).lower() + if key not in _TOOL_INPUT_TARGET_KEYS: + continue + cleaned = _sanitize_tool_target(key, value) + if cleaned is not None: + safe_targets[key] = cleaned + return {"argument_keys": safe_keys, "targets": safe_targets} + + +def _subagent_stop_tool_call_history(tool_trace: Any) -> List[Dict[str, Any]]: + """Build a detached, metadata-only tool history for lifecycle hooks.""" + if not isinstance(tool_trace, list): + return [] + + history: List[Dict[str, Any]] = [] + for item in tool_trace: + if not isinstance(item, dict): + continue + tool_name = str(item.get("tool") or "unknown")[:256] + status = str(item.get("status") or "unknown").lower() + if status not in {"ok", "error"}: + status = "unknown" + + def _byte_count(key: str) -> int: + value = item.get(key, 0) + if not isinstance(value, (int, float)) or isinstance(value, bool): + return 0 + return max(0, int(value)) + + history.append({ + "tool_name": tool_name, + "tool_input": _sanitize_tool_input_summary(item.get("input_summary")), + "input_bytes": _byte_count("args_bytes"), + "output_bytes": _byte_count("result_bytes"), + "status": status, + }) + return history + + def _looks_like_error_output(content: Any) -> bool: """Conservative stderr/error detector for tool-result previews. @@ -2168,9 +2284,11 @@ def _run_single_child( if msg.get("role") == "assistant": for tc in msg.get("tool_calls") or []: fn = tc.get("function", {}) + arguments = fn.get("arguments", "") entry_t = { "tool": fn.get("name", "unknown"), - "args_bytes": len(fn.get("arguments", "")), + "args_bytes": len(arguments), + "input_summary": _summarize_tool_arguments(arguments), } tool_trace.append(entry_t) tc_id = tc.get("id") @@ -2864,6 +2982,9 @@ def delegate_task( child_role=child_role, child_summary=entry.get("summary"), child_status=entry.get("status"), + tool_call_history=_subagent_stop_tool_call_history( + entry.get("tool_trace") + ), duration_ms=int((entry.get("duration_seconds") or 0) * 1000), ) except Exception: diff --git a/website/docs/user-guide/features/hooks.md b/website/docs/user-guide/features/hooks.md index cdb957e3748b..52417bf5741b 100644 --- a/website/docs/user-guide/features/hooks.md +++ b/website/docs/user-guide/features/hooks.md @@ -959,7 +959,7 @@ Fires **once per child agent** after `delegate_task` finishes. Whether you deleg ```python def my_callback(parent_session_id: str, child_role: str | None, child_summary: str | None, child_status: str, - duration_ms: int, **kwargs): + tool_call_history: list[dict], duration_ms: int, **kwargs): ``` | Parameter | Type | Description | @@ -968,6 +968,7 @@ def my_callback(parent_session_id: str, child_role: str | None, | `child_role` | `str \| None` | Orchestrator role tag set on the child (`None` if the feature isn't enabled) | | `child_summary` | `str \| None` | The final response the child returned to the parent | | `child_status` | `str` | `"completed"`, `"failed"`, `"interrupted"`, or `"error"` | +| `tool_call_history` | `list[dict]` | Ordered metadata-only tool calls: `tool_name`, bounded `tool_input`, `input_bytes`, `output_bytes`, and `status`; raw inputs and outputs are excluded | | `duration_ms` | `int` | Wall-clock time spent running the child, in milliseconds | **Fires:** In `tools/delegate_tool.py`, after `ThreadPoolExecutor.as_completed()` drains all child futures. Firing is marshalled to the parent thread so hook authors don't have to reason about concurrent callback execution. From a8c9ad0bccc72a906ff5e59f6cadc559432b5ef7 Mon Sep 17 00:00:00 2001 From: embwl0x <embwl0x@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:21:07 -0500 Subject: [PATCH 04/71] fix(delegate): strip URL userinfo from tool history --- tests/agent/test_subagent_stop_hook.py | 10 +++++++--- tools/delegate_tool.py | 12 +++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/agent/test_subagent_stop_hook.py b/tests/agent/test_subagent_stop_hook.py index 84c0bbe4127b..7bb21d9a60ef 100644 --- a/tests/agent/test_subagent_stop_hook.py +++ b/tests/agent/test_subagent_stop_hook.py @@ -213,6 +213,7 @@ class TestPayloadShape: "argument_keys": ["content", "path", "token"], "targets": { "path": "/private/report.json", + "url": "https://user:password@example.test:8443/upload?token=secret", "token": "must-not-leak", }, }, @@ -226,7 +227,10 @@ class TestPayloadShape: "tool_name": "write_file", "tool_input": { "argument_keys": ["content", "path", "token"], - "targets": {"path": "/private/report.json"}, + "targets": { + "path": "/private/report.json", + "url": "https://example.test:8443/upload", + }, }, "input_bytes": 128, "output_bytes": 32, @@ -237,7 +241,7 @@ class TestPayloadShape: summary = _summarize_tool_arguments(json.dumps({ "path": "/workspace/report.json", "content": "private report contents", - "url": "https://example.test/upload?token=secret#fragment", + "url": "https://user:password@example.test:8443/upload?token=secret#fragment", "command": "curl -H 'Authorization: secret' example.test", })) @@ -245,7 +249,7 @@ class TestPayloadShape: "argument_keys": ["command", "content", "path", "url"], "targets": { "path": "/workspace/report.json", - "url": "https://example.test/upload", + "url": "https://example.test:8443/upload", }, } diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index c7dba38ea828..b1dcd80127ba 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -333,7 +333,17 @@ def _sanitize_tool_target(key: str, value: Any) -> Any: try: parsed = urlsplit(bounded) if parsed.scheme and parsed.netloc: - return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) + hostname = parsed.hostname + if not hostname: + return None + # ``SplitResult.netloc`` includes ``user:password@``. Rebuild + # the authority from parsed host/port so hook-visible history + # cannot carry URL credentials. Bracket IPv6 literals before + # appending a validated port. + host = f"[{hostname}]" if ":" in hostname else hostname + port = parsed.port + netloc = f"{host}:{port}" if port is not None else host + return urlunsplit((parsed.scheme, netloc, parsed.path, "", "")) except ValueError: return None return bounded From 4fbb86d2b8f430d28fc2b10793c481062f27607c Mon Sep 17 00:00:00 2001 From: LeonSGP43 <cine.dreamer.one@gmail.com> Date: Wed, 24 Jun 2026 10:58:30 +0800 Subject: [PATCH 05/71] fix(api): forward subagent lifecycle on run stream --- gateway/platforms/api_server.py | 38 +++++++++++++++++++++++- tests/gateway/test_api_server.py | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index a1b066a85b4a..0c22ae3b9230 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -5975,7 +5975,43 @@ class APIServerAdapter(BasePlatformAdapter): "timestamp": ts, "text": preview or "", }) - # _thinking and subagent_progress are intentionally not forwarded + elif event_type in {"subagent.start", "subagent.complete"}: + event = { + "event": event_type, + "run_id": run_id, + "timestamp": ts, + } + if preview is not None: + event["preview"] = preview + for key in ( + "goal", + "task_count", + "task_index", + "subagent_id", + "parent_id", + "depth", + "model", + "tool_count", + "status", + "summary", + "duration_seconds", + "input_tokens", + "output_tokens", + "reasoning_tokens", + "api_calls", + "cost_usd", + "files_read", + "files_written", + "output_tail", + ): + value = kwargs.get(key) + if value is not None: + event[key] = value + _push(event) + # _thinking, subagent.tool, and subagent_progress are intentionally + # not forwarded on the /v1/runs stream: they are high-volume UI + # noise. Lifecycle boundaries (start/complete) still need to land + # so clients can observe delegate_task timeouts and failures. return _callback diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 347dc423a86d..37eb3f35f343 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -891,6 +891,57 @@ class TestAgentExecution: assert captured["service_tier"] == "priority" +class TestRunEventCallback: + @pytest.mark.asyncio + async def test_forwards_subagent_lifecycle_events(self, adapter): + run_id = "run_subagent_events" + loop = asyncio.get_running_loop() + queue = asyncio.Queue() + adapter._run_streams[run_id] = queue + adapter._run_statuses.pop(run_id, None) + + callback = adapter._make_run_event_callback(run_id, loop) + + callback( + "subagent.start", + preview="research candidate issue", + goal="research candidate issue", + task_index=0, + task_count=1, + subagent_id="deleg_123", + ) + callback( + "subagent.complete", + preview="Timed out after 300s", + goal="research candidate issue", + task_index=0, + task_count=1, + subagent_id="deleg_123", + status="timeout", + duration_seconds=300.0, + summary="Subagent timed out after 300s with 2 API call(s) completed.", + api_calls=2, + ) + + start_event = await asyncio.wait_for(queue.get(), timeout=1.0) + complete_event = await asyncio.wait_for(queue.get(), timeout=1.0) + + assert start_event["event"] == "subagent.start" + assert start_event["preview"] == "research candidate issue" + assert start_event["task_index"] == 0 + assert start_event["task_count"] == 1 + assert start_event["subagent_id"] == "deleg_123" + + assert complete_event["event"] == "subagent.complete" + assert complete_event["preview"] == "Timed out after 300s" + assert complete_event["status"] == "timeout" + assert complete_event["duration_seconds"] == 300.0 + assert complete_event["api_calls"] == 2 + assert "timed out after 300s" in complete_event["summary"] + + assert adapter._run_statuses[run_id]["last_event"] == "subagent.complete" + + # --------------------------------------------------------------------------- # /health endpoint # --------------------------------------------------------------------------- From 666076d13753276faf4ccddbc9c0156c49417297 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:38:34 -0700 Subject: [PATCH 06/71] fix(api): redact subagent stream fields + forward child_session_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardening on top of the salvaged #51642: free-text fields (preview/goal/summary/output_tail) pass redact_sensitive_text(force=True) before leaving on the public /v1/runs SSE stream — same treatment the API already applies to error text — and child_session_id survives the allowlist so clients can correlate the child's session. Both were flagged in the sweeper review of #51642 and unaddressed. --- gateway/platforms/api_server.py | 17 ++++++++++++++--- tests/gateway/test_api_server.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 0c22ae3b9230..209542745efc 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -5982,12 +5982,15 @@ class APIServerAdapter(BasePlatformAdapter): "timestamp": ts, } if preview is not None: - event["preview"] = preview + event["preview"] = redact_sensitive_text( + str(preview), force=True + ) for key in ( "goal", "task_count", "task_index", "subagent_id", + "child_session_id", "parent_id", "depth", "model", @@ -6005,8 +6008,16 @@ class APIServerAdapter(BasePlatformAdapter): "output_tail", ): value = kwargs.get(key) - if value is not None: - event[key] = value + if value is None: + continue + # Free-text fields can carry child terminal/tool output — + # force the same secret redaction the API applies to error + # text before it leaves the process on a public stream. + if key in ("goal", "summary", "output_tail") and isinstance( + value, str + ): + value = redact_sensitive_text(value, force=True) + event[key] = value _push(event) # _thinking, subagent.tool, and subagent_progress are intentionally # not forwarded on the /v1/runs stream: they are high-volume UI diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 37eb3f35f343..4f6a4255fab0 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -941,6 +941,36 @@ class TestRunEventCallback: assert adapter._run_statuses[run_id]["last_event"] == "subagent.complete" + @pytest.mark.asyncio + async def test_subagent_events_redact_secrets_and_carry_child_session(self, adapter): + """Free-text fields (goal/summary/output_tail/preview) must pass the + forced secret redaction before hitting the public /v1/runs stream, + and child_session_id must survive the allowlist so clients can + correlate the child's session.""" + run_id = "run_subagent_redact" + loop = asyncio.get_running_loop() + queue = asyncio.Queue() + adapter._run_streams[run_id] = queue + adapter._run_statuses.pop(run_id, None) + + callback = adapter._make_run_event_callback(run_id, loop) + secret = "sk-proj-abcdef1234567890abcdef1234567890abcdef12" + callback( + "subagent.complete", + preview=f"leaked {secret}", + goal=f"use key {secret} to fetch data", + subagent_id="deleg_999", + child_session_id="child-sess-42", + status="completed", + summary=f"exported OPENAI_API_KEY={secret} then ran", + output_tail=f"env shows {secret}", + ) + + event = await asyncio.wait_for(queue.get(), timeout=1.0) + assert event["child_session_id"] == "child-sess-42" + for field in ("preview", "goal", "summary", "output_tail"): + assert secret not in event[field], field + # --------------------------------------------------------------------------- # /health endpoint From 8a324263002979ec40d4cdd41654ef72da722078 Mon Sep 17 00:00:00 2001 From: Sophia <sophia@hermes.local> Date: Sat, 18 Jul 2026 09:10:17 -0700 Subject: [PATCH 07/71] fix(desktop): preserve live background subagents across message.start (rebased onto main, #64015) Cherry-pick of b4d5ba3e onto current main. Original commit's target file apps/desktop/src/app/session/hooks/use-message-stream.ts has since been moved into apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts. The fix is unchanged: replace clearSessionSubagents() with pruneFinishedSessionSubagents() at the message.start boundary so that still-running subagent rows survive new turns. Per @teknium1's review of #64038 on 2026-07-16: 'mergeable_state=dirty' because the dispatcher moved. Resolved by retargeting the call. Tests: 3 new cases in subagents.test.ts (unchanged from original PR). Failing-first verified in original PR (3/3 fail on unfixed, 3/3 pass on fix). --- .../hooks/use-message-stream/gateway-event.ts | 6 ++- apps/desktop/src/store/subagents.test.ts | 52 +++++++++++++++++++ apps/desktop/src/store/subagents.ts | 29 +++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 4e0a439219ca..eb99e1fbd895 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -47,8 +47,10 @@ import { setTurnStartedAt, setYoloActive } from '@/store/session' -import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents' +import { broadcastSessionsChanged } from '@/store/session-sync' import { clearActiveSessionTodos } from '@/store/todos' +import { clearSessionSubagents, pruneDelegateFallbackSubagents, pruneFinishedSessionSubagents, upsertSubagent } from '@/store/subagents' +import { setSessionTodos } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' import { reportInstallMethodWarning } from '@/store/updates' import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events' @@ -439,7 +441,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { } flushQueuedDeltas(sessionId) - clearSessionSubagents(sessionId) + pruneFinishedSessionSubagents(sessionId) setSessionCompacting(sessionId, false) compactedTurnRef.current.delete(sessionId) nativeSubagentSessionsRef.current.delete(sessionId) diff --git a/apps/desktop/src/store/subagents.test.ts b/apps/desktop/src/store/subagents.test.ts index c0b87ba58eae..51c752ddea22 100644 --- a/apps/desktop/src/store/subagents.test.ts +++ b/apps/desktop/src/store/subagents.test.ts @@ -8,6 +8,7 @@ import { clearSessionSubagents, failedSubagentCount, pruneDelegateFallbackSubagents, + pruneFinishedSessionSubagents, upsertSubagent } from './subagents' @@ -131,4 +132,55 @@ describe('subagent store', () => { expect($subagentsBySession.get().s1).toBeUndefined() expect($subagentsBySession.get().s2).toHaveLength(1) }) + + // Regression test for #64015: still-RUNNING background subagents must survive + // the per-turn wipe that previously dropped them at message.start. The fix + // replaces clearSessionSubagents() with pruneFinishedSessionSubagents() at + // the use-message-stream message.start handler, so only terminal-status rows + // get filtered out. + it('pruneFinishedSessionSubagents keeps running/queued and drops terminal rows', () => { + upsertSubagent('s1', { goal: 'live-a', status: 'running', subagent_id: 'live-a', task_index: 0 }) + upsertSubagent('s1', { goal: 'live-b', status: 'queued', subagent_id: 'live-b', task_index: 1 }) + upsertSubagent('s1', { goal: 'done', status: 'completed', subagent_id: 'done', task_index: 2 }) + upsertSubagent('s1', { goal: 'broken', status: 'failed', subagent_id: 'broken', task_index: 3 }) + upsertSubagent('s1', { goal: 'cancelled', status: 'interrupted', subagent_id: 'cancelled', task_index: 4 }) + + pruneFinishedSessionSubagents('s1') + + const ids = listFor('s1').map(item => item.id).sort() + expect(ids).toEqual(['live-a', 'live-b']) + expect(activeSubagentCount(listFor('s1'))).toBe(2) + }) + + // Companion test: after prune, a late `subagent.complete` event for a + // surviving live row must still be accepted by upsertSubagent (the wipe + // path previously silently dropped these). + it('surviving live subagents still accept createIfMissing=false completion', () => { + upsertSubagent('s1', { goal: 'live', status: 'running', subagent_id: 'live', task_index: 0 }) + + pruneFinishedSessionSubagents('s1') + + upsertSubagent( + 's1', + { status: 'completed', subagent_id: 'live', task_index: 0, summary: 'finished later' }, + false, + 'subagent.complete' + ) + + const item = listFor('s1')[0] + expect(item?.status).toBe('completed') + expect(item?.summary).toBe('finished later') + }) + + it('pruneFinishedSessionSubagents leaves other sessions untouched', () => { + upsertSubagent('s1', { goal: 'live', status: 'running', subagent_id: 'a', task_index: 0 }) + upsertSubagent('s1', { goal: 'done', status: 'completed', subagent_id: 'b', task_index: 1 }) + upsertSubagent('s2', { goal: 'live', status: 'running', subagent_id: 'c', task_index: 0 }) + upsertSubagent('s2', { goal: 'done', status: 'completed', subagent_id: 'd', task_index: 1 }) + + pruneFinishedSessionSubagents('s1') + + expect(listFor('s1').map(item => item.id)).toEqual(['a']) + expect(listFor('s2').map(item => item.id).sort()).toEqual(['c', 'd']) + }) }) diff --git a/apps/desktop/src/store/subagents.ts b/apps/desktop/src/store/subagents.ts index e54f3fc169b2..6127b15183c2 100644 --- a/apps/desktop/src/store/subagents.ts +++ b/apps/desktop/src/store/subagents.ts @@ -189,6 +189,35 @@ export function clearSessionSubagents(sid: string) { $subagentsBySession.set(rest) } +/** + * Prune terminal-status subagent rows for a session, leaving running/queued + * entries untouched. Used at the `message.start` boundary in the desktop + * message-stream hook so that the *previous* turn's finished rows get flushed + * from the display while background subagents that outlived the spawning turn + * remain visible (and still accept late progress/complete events). + * + * Distinct from `clearSessionSubagents` (used by the Stop action, which + * genuinely cancels running subagents and so should drop them all) and from + * `pruneDelegateFallbackSubagents` (which filters by id prefix to remove + * placeholder rows once the real native event arrives). + */ +export function pruneFinishedSessionSubagents(sid: string) { + const map = $subagentsBySession.get() + const list = map[sid] + + if (!list?.length) { + return + } + + const next = list.filter(item => item.status === 'running' || item.status === 'queued') + + if (next.length === list.length) { + return + } + + $subagentsBySession.set({ ...map, [sid]: next }) +} + export function pruneDelegateFallbackSubagents(sid: string) { const map = $subagentsBySession.get() const list = map[sid] From 14c754cda466b70f2a309497bc9973e2dc6600ff Mon Sep 17 00:00:00 2001 From: Sophia <sophia@hermes.local> Date: Wed, 22 Jul 2026 16:22:28 -0700 Subject: [PATCH 08/71] fix(desktop): remove unused imports from gateway-event.ts Per hermes-sweeper review on PR #67005: - Remove `broadcastSessionsChanged` import (unused) - Remove `setSessionTodos` import (unused) - Keep `clearActiveSessionTodos` and other active imports No behavioral change, just dead code removal. --- .../src/app/session/hooks/use-message-stream/gateway-event.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index eb99e1fbd895..d29c4abecd2b 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -47,10 +47,8 @@ import { setTurnStartedAt, setYoloActive } from '@/store/session' -import { broadcastSessionsChanged } from '@/store/session-sync' import { clearActiveSessionTodos } from '@/store/todos' import { clearSessionSubagents, pruneDelegateFallbackSubagents, pruneFinishedSessionSubagents, upsertSubagent } from '@/store/subagents' -import { setSessionTodos } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' import { reportInstallMethodWarning } from '@/store/updates' import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events' From 1c5387105ad8b45b408ef4849cdb4f5c28441c2d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:41:04 -0700 Subject: [PATCH 09/71] chore(contributors): map sophia@hermes.local -> knoal for #67005 salvage --- contributors/emails/sophia@hermes.local | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/sophia@hermes.local diff --git a/contributors/emails/sophia@hermes.local b/contributors/emails/sophia@hermes.local new file mode 100644 index 000000000000..cbb830b4d807 --- /dev/null +++ b/contributors/emails/sophia@hermes.local @@ -0,0 +1 @@ +knoal From f8918391d9469ed9e185944c7e477a497afa96b7 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:55:01 -0700 Subject: [PATCH 10/71] fix(desktop): drop now-unused clearSessionSubagents import in gateway-event The message.start site swapped to pruneFinishedSessionSubagents; the old import survived the rebase and trips unused-imports + sort-imports lint. --- .../src/app/session/hooks/use-message-stream/gateway-event.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index d29c4abecd2b..00348861930b 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -47,8 +47,8 @@ import { setTurnStartedAt, setYoloActive } from '@/store/session' +import { pruneDelegateFallbackSubagents, pruneFinishedSessionSubagents, upsertSubagent } from '@/store/subagents' import { clearActiveSessionTodos } from '@/store/todos' -import { clearSessionSubagents, pruneDelegateFallbackSubagents, pruneFinishedSessionSubagents, upsertSubagent } from '@/store/subagents' import { recordToolDiff } from '@/store/tool-diffs' import { reportInstallMethodWarning } from '@/store/updates' import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events' From ece050ac300c1e31ca8187c550de6fc13911eb2f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:47:27 -0700 Subject: [PATCH 11/71] fix(delegation): route delegated-child API calls inline to avoid nested-pool wedge (#60203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: delegate_task children run through three nested daemon-thread layers (async-delegation executor -> per-child timeout executor -> the interrupt worker interruptible_api_call spawns). After multi-day gateway uptime the deepest layer wedges BEFORE the socket opens — the same fingerprint as the gateway-cron hang (#62151): zero stale-detector output (the worker never reaches dispatch), all providers, foreground/restart works. The cron fix (should_use_direct_api_call) explicitly excluded delegation 'for lack of evidence' — #60203 is that evidence. - should_use_direct_api_call: extend the inline gate to delegated children, detected via the delegation ContextVar set by _run_single_child (platform='subagent' stamp as fallback). Scope unchanged otherwise: chat_completions wire only; Codex/Anthropic/ Bedrock/MoA keep their established workers. Interrupts still work — the inline path registers _active_request_abort, which interrupt() invokes cross-thread (same mechanism the #72227 stall monitor uses). - _dump_subagent_timeout_diagnostic: dump ALL thread stacks (bounded, 40), not just the conversation worker — a pre-HTTP wedge is indistinguishable from a slow provider without seeing where the nested helper threads sit. --- agent/chat_completion_helpers.py | 58 ++++++++++++++----- tests/cron/test_cron_direct_api_call_62151.py | 28 +++++++++ tools/delegate_tool.py | 32 ++++++++++ 3 files changed, 104 insertions(+), 14 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 3689b4d5be83..dcf88e7fd0d9 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -433,26 +433,56 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client): def should_use_direct_api_call(agent) -> bool: - """Whether a cron OpenAI-wire request should skip the interrupt worker. + """Whether an OpenAI-wire request should skip the interrupt worker. - Issue #62151 is specific to OpenRouter's chat-completions path inside the - gateway cron thread stack. Keep native/Codex/Bedrock/MoA transports on their - established workers: their cancellation and client ownership differ, and - the report provides no evidence that those paths share the pre-HTTP wedge. + Two nested-pool contexts wedge before the socket opens when the request + is pushed onto yet another daemon worker thread: + + - Gateway cron turns (#62151): gateway asyncio loop → cron thread → + interrupt worker. Fixed by running inline. + - Delegated children (#60203): gateway loop → async-delegation executor + (module-lifetime daemon pool) → per-child timeout executor → interrupt + worker. Same fingerprint after multi-day gateway uptime — children hang + at their FIRST API call with zero stale-detector output (the worker + never reaches dispatch), all providers, restart cures it. The cron fix + originally excluded delegation "for lack of evidence"; #60203 is that + evidence. + + Running inline drops the deepest thread layer (whose only job is + interactive-interrupt responsiveness). Interrupts still work: the inline + path registers ``agent._active_request_abort``, which ``interrupt()`` + invokes cross-thread to shut the active sockets — the same mechanism the + async-delegation stall monitor (#72227) relies on. + + Keep native/Codex/Bedrock/MoA transports on their established workers: + their cancellation and client ownership differ. """ - return ( - getattr(agent, "platform", None) == "cron" - and getattr(agent, "api_mode", None) == "chat_completions" - and getattr(agent, "provider", None) != "moa" - ) + if getattr(agent, "api_mode", None) != "chat_completions": + return False + if getattr(agent, "provider", None) == "moa": + return False + if getattr(agent, "platform", None) == "cron": + return True + # Delegated child (delegate_task sync or background) — detected via the + # execution ContextVar set by _run_single_child, with the agent's own + # platform stamp as a fallback for callers that bypass the runner. + try: + from agent.delegation_context import is_delegated_child_context + + if is_delegated_child_context(): + return True + except Exception: + pass + return getattr(agent, "platform", None) == "subagent" def direct_api_call(agent, api_kwargs: dict): """Run a non-streaming LLM call inline on the conversation thread. - Used when ``should_use_direct_api_call`` is True. Skips the interrupt worker - (whose only job is interactive-interrupt responsiveness, which this context - does not have) so the nested-pool deadlock (#62151) cannot occur. Because the + Used when ``should_use_direct_api_call`` is True (cron turns and + delegated children). Skips the interrupt worker (whose only job is + interactive-interrupt responsiveness, which these contexts do not have) + so the nested-pool deadlock (#62151, #60203) cannot occur. Because the request runs in-flight normally, the per-request OpenAI client's own httpx timeout (provider ``request_timeout_seconds`` / ``HERMES_API_TIMEOUT``) bounds a genuinely hung provider — the same bound interactive calls already rely on. @@ -463,7 +493,7 @@ def direct_api_call(agent, api_kwargs: dict): request_client_lock = threading.Lock() def _abort_active_request(reason: str) -> None: - """Abort the inline request from cron's watchdog/interrupt thread.""" + """Abort the inline request from a watchdog/interrupt thread.""" with request_client_lock: request_client = request_client_holder["client"] if request_client is not None: diff --git a/tests/cron/test_cron_direct_api_call_62151.py b/tests/cron/test_cron_direct_api_call_62151.py index f6c4c19d5305..5e63339997b1 100644 --- a/tests/cron/test_cron_direct_api_call_62151.py +++ b/tests/cron/test_cron_direct_api_call_62151.py @@ -45,6 +45,34 @@ def test_should_use_direct_api_call_only_for_cron_openai_wire(): assert should_use_direct_api_call(moa) is False +def test_should_use_direct_api_call_for_delegated_children(): + """#60203: delegated children share the cron nested-pool wedge and must + take the inline path — via the delegation ContextVar (how the runner + executes them) or the platform='subagent' stamp as a fallback.""" + from agent.delegation_context import delegated_child_context + + # Platform stamp alone (child agents are built with platform="subagent"). + assert should_use_direct_api_call(_make_agent(platform="subagent")) is True + + # ContextVar path: any platform, running inside _run_single_child's + # delegated_child_context(). + agent = _make_agent(platform="cli") + with delegated_child_context(): + assert should_use_direct_api_call(agent) is True + assert should_use_direct_api_call(agent) is False # reset outside + + # Non-OpenAI-wire children keep their established transports. + for api_mode in ("codex_responses", "anthropic_messages", "bedrock_converse"): + child = _make_agent(platform="subagent") + child.api_mode = api_mode + assert should_use_direct_api_call(child) is False + + # MoA children keep the worker path. + moa_child = _make_agent(platform="subagent") + moa_child.provider = "moa" + assert should_use_direct_api_call(moa_child) is False + + def test_direct_api_call_runs_two_sequential_requests_on_same_thread(): """Mirror the 2nd+ call failure mode: two back-to-back completions.create.""" agent = _make_agent() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index b1dcd80127ba..b3019af05032 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1623,6 +1623,7 @@ def _dump_subagent_timeout_diagnostic( import datetime as _dt import sys as _sys import traceback as _traceback + import threading as _threading hermes_home = get_hermes_home() logs_dir = hermes_home / "logs" @@ -1729,6 +1730,37 @@ def _dump_subagent_timeout_diagnostic( _w(" <worker thread already exited>") _w("") + # All other live threads. The conversation worker's own stack often + # shows it parked waiting on a nested helper thread (interrupt worker, + # daemon-pool sibling) — without the full picture, a pre-HTTP wedge + # (#60203/#62151) is indistinguishable from a slow provider. Best + # effort and bounded: names + stacks for up to 40 threads. + _w("## All thread stacks at timeout") + try: + frames = _sys._current_frames() + by_ident = { + th.ident: th for th in _threading.enumerate() if th.ident + } + worker_ident = worker_thread.ident if worker_thread else None + dumped = 0 + for ident, frame in frames.items(): + if ident == worker_ident: + continue # already dumped above + if dumped >= 40: + _w(f" <{len(frames) - dumped - 1} more threads omitted>") + break + th = by_ident.get(ident) + name = th.name if th else f"ident={ident}" + daemon = " daemon" if (th and th.daemon) else "" + _w(f" --- {name}{daemon} ---") + for frame_line in _traceback.format_stack(frame): + for sub in frame_line.rstrip().split("\n"): + _w(f" {sub}") + dumped += 1 + except Exception as exc: + _w(f" <all-thread dump failed: {exc}>") + _w("") + _w("## Notes") _w(" This file is written ONLY when a subagent times out with 0 API calls.") _w(" 0-API-call timeouts mean the child never reached its first LLM request.") From ef60509a26d07b40c4f512f864c8242a5bce4076 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:59:41 -0700 Subject: [PATCH 12/71] test(delegate): model socket-abort interrupt for the inline child API path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_interrupt_child_during_api_call pinned the OLD worker-thread contract: a bare time.sleep(5) fake request that only 'interrupts fast' because the worker thread gets abandoned. Delegated children now run the request INLINE (#60203) and interrupt responsiveness comes from the cross-thread socket abort (_abort_request_openai_client) — which a sleep can't feel. Wire the fake request to an abort event that raises ConnectionError when the child's abort hook fires, exactly as a real httpx recv unblocks on socket shutdown. Sabotage-verified: disabling the abort hook fails the test at the full 5s; with it the interrupt lands in ~10ms. --- .../run_agent/test_real_interrupt_subagent.py | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/tests/run_agent/test_real_interrupt_subagent.py b/tests/run_agent/test_real_interrupt_subagent.py index a76fb3f84fb2..1e2bca760541 100644 --- a/tests/run_agent/test_real_interrupt_subagent.py +++ b/tests/run_agent/test_real_interrupt_subagent.py @@ -13,11 +13,24 @@ from unittest.mock import MagicMock, patch from tools.interrupt import set_interrupt -def _make_slow_api_response(delay=5.0): - """Create a mock that simulates a slow API response (like a real LLM call).""" +def _make_slow_api_response(delay=5.0, abort_event=None): + """Create a mock that simulates a slow API response (like a real LLM call). + + When ``abort_event`` is provided, the fake call unblocks early if the + event fires and raises a connection error — modelling what a real httpx + request does when ``_abort_request_openai_client`` shuts down the + client's TCP sockets (the pending recv returns EOF). Delegated children + run their request INLINE on the conversation thread (#60203), so + interrupt responsiveness there comes from the socket abort, not from + abandoning a worker thread; a bare ``time.sleep`` cannot simulate that. + """ def slow_create(**kwargs): # Simulate a slow API call - time.sleep(delay) + if abort_event is not None: + if abort_event.wait(delay): + raise ConnectionError("socket shut down by interrupt abort") + else: + time.sleep(delay) # Return a simple text response (no tool calls) resp = MagicMock() resp.choices = [MagicMock()] @@ -89,13 +102,24 @@ class TestRealSubagentInterrupt(unittest.TestCase): # Patch the OpenAI client creation inside AIAgent.__init__ with patch('run_agent.OpenAI') as MockOpenAI: mock_client = MagicMock() - # API call takes 5 seconds — should be interrupted before that - mock_client.chat.completions.create = _make_slow_api_response(delay=5.0) + # API call takes 5 seconds — should be interrupted before + # that. Children run the request INLINE (#60203), so the + # interrupt path is the cross-thread socket abort: wire + # the fake request to unblock when the child's abort hook + # fires, as a real httpx recv would on socket shutdown. + abort_event = threading.Event() + mock_client.chat.completions.create = _make_slow_api_response( + delay=5.0, abort_event=abort_event + ) mock_client.close = MagicMock() MockOpenAI.return_value = mock_client + def fake_abort(self_agent, client, *, reason): + abort_event.set() + # Patch the instance method so it skips prompt assembly - with patch.object(AIAgent, '_build_system_prompt', return_value="You are a test agent"): + with patch.object(AIAgent, '_build_system_prompt', return_value="You are a test agent"), \ + patch.object(AIAgent, '_abort_request_openai_client', fake_abort): # Signal when child starts original_run = AIAgent.run_conversation From 5b9518db418ce6482b6aa6fbece772ffa7191337 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Sun, 26 Jul 2026 22:38:33 -0500 Subject: [PATCH 13/71] feat(statusbar): hide the per-turn session readouts by default The running/session timers and the context meter light up whenever a session works, so the bar filled with diagnostics most users don't watch every turn. They join the same right-click show/hide menu the route shortcuts and terminal toggle already use: hidden out of the box, opt in via a toggleLabel, choice persisted per install. --- apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx | 3 +++ apps/desktop/src/i18n/en.ts | 3 +++ apps/desktop/src/i18n/types.ts | 3 +++ apps/desktop/src/i18n/zh.ts | 3 +++ apps/desktop/src/store/statusbar-prefs.ts | 7 ++++++- 5 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 05d3e9717dce..684433314e9e 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -493,6 +493,7 @@ export function useStatusbarItems({ id: 'running-timer', label: copy.turnRunning, title: copy.currentTurnElapsed, + toggleLabel: copy.toggleRunningTimer, variant: 'text' }, { @@ -511,6 +512,7 @@ export function useStatusbarItems({ /> ), title: copy.openContextUsage, + toggleLabel: copy.toggleContextUsage, variant: 'menu' }, { @@ -519,6 +521,7 @@ export function useStatusbarItems({ id: 'session-timer', label: copy.session, title: copy.runtimeSessionElapsed, + toggleLabel: copy.toggleSessionTimer, variant: 'text' }, { diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 409faf98772a..abdd8c4c402a 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2430,6 +2430,9 @@ export const en: Translations = { toggleApprovalMode: 'Approvals', toggleBackendVersion: 'Backend version', toggleCommandCenter: 'Command Center', + toggleContextUsage: 'Context meter', + toggleRunningTimer: 'Turn timer', + toggleSessionTimer: 'Session timer', toggleTerminal: 'Terminal', toggleVersion: 'Version & updates', toggleWorkspace: 'Workspace', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index e15db82546a1..aacf8641e25d 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2036,6 +2036,9 @@ export interface Translations { toggleApprovalMode: string toggleBackendVersion: string toggleCommandCenter: string + toggleContextUsage: string + toggleRunningTimer: string + toggleSessionTimer: string toggleTerminal: string toggleVersion: string toggleWorkspace: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 71f033580aaf..ad3e22e60b71 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2606,6 +2606,9 @@ export const zh: Translations = { toggleApprovalMode: '审批', toggleBackendVersion: '后端版本', toggleCommandCenter: '命令中心', + toggleContextUsage: '上下文用量', + toggleRunningTimer: '回合计时', + toggleSessionTimer: '会话计时', toggleTerminal: '终端', toggleVersion: '版本与更新', toggleWorkspace: '工作区', diff --git a/apps/desktop/src/store/statusbar-prefs.ts b/apps/desktop/src/store/statusbar-prefs.ts index 3de112337625..177aa393f08a 100644 --- a/apps/desktop/src/store/statusbar-prefs.ts +++ b/apps/desktop/src/store/statusbar-prefs.ts @@ -5,11 +5,16 @@ const STATUSBAR_HIDDEN_STORAGE_KEY = 'hermes.desktop.statusbarHidden' // Items the bar hides until the user turns them on from its context menu. The // bar's job is to answer "is the backend healthy, where am I, what's it doing" — // route shortcuts (cron/webhooks/agents), the terminal toggle, and the approval -// pill are navigation, not status, so they start out of the way. +// pill are navigation, not status, so they start out of the way. The per-turn +// session readouts (running/session timers, context meter) are diagnostics most +// users don't watch, so they start hidden too and the bar stays quiet mid-turn. export const STATUSBAR_HIDDEN_BY_DEFAULT: readonly string[] = [ 'agents', 'approval-mode', + 'context-usage', 'cron', + 'running-timer', + 'session-timer', 'terminal', 'webhooks' ] From f7cfc6ecd318a112c535e12068b36b21470417e4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Sun, 26 Jul 2026 22:38:33 -0500 Subject: [PATCH 14/71] test(statusbar): cover the session readouts starting hidden --- .../app/shell/statusbar-visibility.test.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/desktop/src/app/shell/statusbar-visibility.test.tsx b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx index cb26f286aa8c..04defbe29479 100644 --- a/apps/desktop/src/app/shell/statusbar-visibility.test.tsx +++ b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx @@ -96,4 +96,25 @@ describe('statusbar item visibility', () => { expect(screen.getByText('Plugin thing')).toBeTruthy() }) + + it('starts the per-turn session readouts hidden and restores them from the menu', async () => { + const statusbar = bar([ + item('running-timer', 'Turn timer', { variant: 'text' }), + item('context-usage', 'Context meter', { variant: 'menu' }), + item('session-timer', 'Session timer', { variant: 'text' }), + item('gateway-health', 'Gateway') + ]) + + for (const label of ['Turn timer', 'Context meter', 'Session timer']) { + expect(screen.queryByText(label)).toBeNull() + } + + openContextMenu(statusbar) + + const row = await screen.findByRole('menuitemcheckbox', { name: 'Session timer' }) + fireEvent.click(row) + + expect($statusbarHiddenIds.get()).not.toContain('session-timer') + expect(within(statusbar).getByText('Session timer')).toBeTruthy() + }) }) From 6437701228a907c0c87642b3be00ba62a32f432c Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:10:55 -0700 Subject: [PATCH 15/71] feat(approval): require approval for docker/podman daemon-redirect commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspired by Claude Code 2.1.214, which added permission prompts for container-CLI commands (including the Podman shim) carrying daemon-redirect flags (--url, --connection, --identity, remote mode) that previously ran without one. A daemon redirect makes a local-looking command operate on a different (often remote) daemon, silently acting on production infrastructure. Any container-CLI invocation carrying a redirect now requires approval regardless of subcommand: - -H/--host and --context global flags (value required, global-flag position only — bare -h help and run-level -h <hostname> stay allowed) - context use (persistently switches the default daemon) - podman --url/--connection/--identity and -r/--remote - DOCKER_HOST=/DOCKER_CONTEXT=/CONTAINER_HOST=/CONTAINER_CONNECTION= environment prefixes Sibling-site widening: the existing container lifecycle rules matched only the verb directly adjacent to the binary name, so a global flag or a compose -f file flag slipped past the guard, and the legacy hyphenated compose binary was never covered. They now tolerate global flags — the same treatment the 'hermes ... gateway' rule already has — and match the hyphenated compose binary. Validation: 33 new tests; 339 pass in test_approval.py + new file; 442 pass across the adjacent guard suites; E2E battery of 12 dangerous + 17 safe commands via real imports, hot path ~330us/call. --- tests/tools/test_docker_daemon_redirect.py | 155 +++++++++++++++++++++ tools/approval.py | 36 ++++- website/docs/user-guide/security.md | 4 + 3 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 tests/tools/test_docker_daemon_redirect.py diff --git a/tests/tools/test_docker_daemon_redirect.py b/tests/tools/test_docker_daemon_redirect.py new file mode 100644 index 000000000000..37793d9c1620 --- /dev/null +++ b/tests/tools/test_docker_daemon_redirect.py @@ -0,0 +1,155 @@ +"""Docker/Podman daemon-redirect and lifecycle flag-insertion detection. + +Inspired by Claude Code 2.1.214, which added permission prompts for docker +commands (including the Podman ``docker`` shim) carrying daemon-redirect +flags (``--url``, ``--connection``, ``--identity``, and Podman's remote +mode) that previously ran without one. + +A daemon redirect makes a local-looking command operate on a different +(often remote) daemon, so any docker/podman invocation carrying one +requires approval regardless of subcommand. +""" + +from tools.approval import detect_dangerous_command + + +class TestDockerDaemonRedirect: + def test_docker_dash_h_remote_host(self): + is_dangerous, key, desc = detect_dangerous_command( + "docker -H ssh://prod-host stop app") + assert is_dangerous is True + assert key is not None + assert "daemon redirect" in desc + + def test_docker_long_host_flag_equals_form(self): + is_dangerous, _, desc = detect_dangerous_command( + "docker --host=tcp://10.0.0.5:2375 ps") + assert is_dangerous is True + assert "daemon redirect" in desc + + def test_docker_host_flag_after_other_global_flags(self): + is_dangerous, _, desc = detect_dangerous_command( + "docker --log-level debug -H tcp://10.0.0.5:2375 images") + assert is_dangerous is True + assert "daemon redirect" in desc + + def test_docker_context_flag(self): + is_dangerous, _, desc = detect_dangerous_command( + "docker --context production rm -f db") + assert is_dangerous is True + assert "daemon redirect" in desc + + def test_docker_context_use(self): + is_dangerous, _, desc = detect_dangerous_command( + "docker context use production") + assert is_dangerous is True + assert "context use" in desc + + def test_docker_host_env_prefix(self): + is_dangerous, _, _ = detect_dangerous_command( + "DOCKER_HOST=ssh://prod docker stop app") + assert is_dangerous is True + + def test_docker_context_env_prefix(self): + is_dangerous, _, _ = detect_dangerous_command( + "DOCKER_CONTEXT=production docker ps") + assert is_dangerous is True + + def test_container_host_env_prefix(self): + is_dangerous, _, _ = detect_dangerous_command( + "CONTAINER_HOST=ssh://root@prod:22/run/podman/podman.sock podman ps") + assert is_dangerous is True + + def test_podman_url_flag(self): + is_dangerous, _, desc = detect_dangerous_command( + "podman --url ssh://core@remote:22/run/podman.sock ps") + assert is_dangerous is True + assert "daemon redirect" in desc + + def test_podman_connection_flag(self): + is_dangerous, _, _ = detect_dangerous_command( + "podman --connection prod rm -f web") + assert is_dangerous is True + + def test_podman_identity_flag(self): + is_dangerous, _, _ = detect_dangerous_command( + "podman --identity ~/.ssh/id_ed25519 --url ssh://x ps") + assert is_dangerous is True + + def test_podman_remote_mode(self): + is_dangerous, _, desc = detect_dangerous_command("podman --remote ps") + assert is_dangerous is True + assert "remote mode" in desc + + def test_podman_short_remote_flag(self): + is_dangerous, _, _ = detect_dangerous_command("podman -r images") + assert is_dangerous is True + + # -- negatives: local docker usage stays out of the deny ---------------- + + def test_plain_docker_ps_not_flagged(self): + assert detect_dangerous_command("docker ps -a") == (False, None, None) + + def test_docker_run_not_flagged(self): + assert detect_dangerous_command( + "docker run --rm -it alpine sh") == (False, None, None) + + def test_docker_bare_help_flag_not_flagged(self): + # `docker -h` alone is help; the redirect rule requires a value token. + assert detect_dangerous_command("docker -h") == (False, None, None) + + def test_docker_run_hostname_flag_not_flagged(self): + # `-h` in the subcommand position is `docker run --hostname`. + assert detect_dangerous_command( + "docker run -h myhost alpine") == (False, None, None) + + def test_docker_build_not_flagged(self): + assert detect_dangerous_command( + "docker build -t myimage .") == (False, None, None) + + def test_docker_context_ls_not_flagged(self): + assert detect_dangerous_command( + "docker context ls") == (False, None, None) + + def test_podman_local_ps_not_flagged(self): + assert detect_dangerous_command("podman ps") == (False, None, None) + + def test_podman_local_rm_not_misattributed_to_redirect(self): + is_dangerous, _, desc = detect_dangerous_command( + "podman rm old-container") + if is_dangerous: + assert "remote" not in desc + + +class TestDockerLifecycleFlagInsertion: + """Global flags must not slip a lifecycle verb past the docker guard.""" + + def test_docker_stop_still_flagged(self): + is_dangerous, _, desc = detect_dangerous_command("docker stop app") + assert is_dangerous is True + assert "container lifecycle" in desc + + def test_docker_stop_with_global_flag_flagged(self): + is_dangerous, _, desc = detect_dangerous_command( + "docker --log-level debug stop app") + assert is_dangerous is True + assert "container lifecycle" in desc + + def test_docker_compose_down_with_file_flag_flagged(self): + is_dangerous, _, desc = detect_dangerous_command( + "docker compose -f docker-compose.prod.yml down") + assert is_dangerous is True + assert "container lifecycle" in desc + + def test_legacy_docker_compose_binary_down_flagged(self): + is_dangerous, _, desc = detect_dangerous_command("docker-compose down") + assert is_dangerous is True + assert "container lifecycle" in desc + + def test_docker_compose_up_not_flagged(self): + assert detect_dangerous_command( + "docker compose -f dev.yml up -d") == (False, None, None) + + def test_docker_run_restart_policy_not_flagged(self): + assert detect_dangerous_command( + "docker run --restart=always -d nginx") == (False, None, None) diff --git a/tools/approval.py b/tools/approval.py index f7d15b8ab2a6..4b27bfdfb002 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -690,8 +690,40 @@ DANGEROUS_PATTERNS = [ # containers without approval. These are agent-initiated lifecycle operations # that should always require user consent, just like `hermes gateway restart` # already does for the gateway process. - (r'\bdocker\s+compose\s+(restart|stop|kill|down)\b', "docker compose restart/stop/kill/down (container lifecycle)"), - (r'\bdocker\s+(restart|stop|kill)\b', "docker restart/stop/kill (container lifecycle)"), + # Docker/Podman daemon redirect — global flags or env prefixes that point + # the CLI at a DIFFERENT daemon, often a remote host over ssh/tcp. A + # command that looks local (`docker -H ssh://prod stop app`) silently + # operates on remote infrastructure, so any docker/podman invocation + # carrying a redirect requires approval regardless of subcommand. The + # redirect flag must appear in the global-flag position (before the + # subcommand) and -H/--host/--context must carry a value, which keeps + # `docker -h` (help) and subcommand flags like `docker run -h <hostname>` + # out of the deny. Listed BEFORE the lifecycle rules so a redirected + # lifecycle command surfaces the more specific "remote daemon" reason. + # Inspired by Claude Code 2.1.214, which added permission prompts for + # docker/podman commands carrying daemon-redirect flags (--url, + # --connection, --identity, remote mode). + (r'\bdocker\s+(?:-{1,2}\S+(?:[=\s]\S+)?\s+)*(?:-h|--host)[=\s]+\S+', + "docker with remote daemon redirect (-H/--host)"), + (r'\bdocker\s+(?:-{1,2}\S+(?:[=\s]\S+)?\s+)*(?:-c|--context)[=\s]+\S+', + "docker with daemon redirect (--context: alternate daemon)"), + (r'\bdocker\s+context\s+use\b', + "docker context use (switches default daemon for future commands)"), + (r'\bpodman\s+(?:-{1,2}\S+(?:[=\s]\S+)?\s+)*(?:--url|--connection|--identity)[=\s]+\S+', + "podman with remote daemon redirect (--url/--connection/--identity)"), + (r'\bpodman\s+(?:-{1,2}\S+(?:[=\s]\S+)?\s+)*(?:-r\b|--remote\b)', + "podman remote mode (-r/--remote: remote daemon)"), + (r'\b(?:docker_host|docker_context|container_host|container_connection)=\S+', + "docker/podman daemon redirect via environment (DOCKER_HOST/CONTAINER_HOST)"), + # Allow global flags between `docker`/`compose` and the verb (e.g. + # `docker compose -f prod.yml down`, `docker --log-level debug stop app`) + # and the legacy hyphenated `docker-compose` binary, so a flag can't slip + # a lifecycle command past the guard — same treatment as the `hermes ... + # gateway` pattern above. + (r'\bdocker(?:-compose|\s+compose)\s+(?:-{1,2}\S+(?:[=\s]\S+)?\s+)*(restart|stop|kill|down)\b', + "docker compose restart/stop/kill/down (container lifecycle)"), + (r'\bdocker\s+(?:-{1,2}\S+(?:[=\s]\S+)?\s+)*(restart|stop|kill)\b', + "docker restart/stop/kill (container lifecycle)"), # Gateway protection: never start gateway outside systemd management (r'gateway\s+run\b.*(&\s*$|&\s*;|\bdisown\b|\bsetsid\b)', "start gateway outside systemd (use 'systemctl --user restart hermes-gateway')"), (r'\bnohup\b.*gateway\s+run\b', "start gateway outside systemd (use 'systemctl --user restart hermes-gateway')"), diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index c94ee53ea175..3e920b953a0d 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -182,6 +182,10 @@ The following patterns trigger approval prompts (defined in `tools/approval.py`) | `sed -i` / `sed --in-place` on `/etc/` | In-place edit of system config | | `pkill`/`killall` hermes/gateway | Self-termination prevention | | `gateway run` with `&`/`disown`/`nohup`/`setsid` | Prevents starting gateway outside service manager | +| `docker stop/kill/restart`, `docker compose down/stop/kill/restart` | Container lifecycle (also catches global flags and `docker-compose`) | +| `docker -H`/`--host`/`--context`, `DOCKER_HOST=`/`DOCKER_CONTEXT=` | Docker daemon redirect — the command targets a different (often remote) daemon | +| `docker context use` | Switches the default daemon for all future docker commands | +| `podman --remote`/`-r`/`--url`/`--connection`/`--identity`, `CONTAINER_HOST=` | Podman remote daemon redirect | :::info **Container bypass**: When running in `docker`, `singularity`, `modal`, or `daytona` backends, dangerous command checks are **skipped** because the container itself is the security boundary. Destructive commands inside a container can't harm the host. From c4d19132949b7a41727250aa5156f66f122adb47 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:12:04 -0700 Subject: [PATCH 16/71] fix(tools): normalize Unicode space family and minus sign in patch fuzzy matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from anomalyco/opencode#38133/#38134 (patch Unicode matching corpus): extend UNICODE_MAP with the Zs space-separator family (en/em quad, en/em/ three-per-em/four-per-em/six-per-em/figure/punctuation/thin/hair spaces, narrow NBSP, medium mathematical space, CJK ideographic space) and the Unicode minus sign U+2212. Before: a file containing typographic spacing (French narrow NBSP, CJK ideographic spaces, math minus) never matched a model's ASCII old_string via the precise strategies — the edit only succeeded through the similarity-based context_aware fallback, which (a) can pick the wrong region (#54572 family) and (b) silently flattens the file's Unicode to ASCII on replacement. After: these match at unicode_normalized (strategy 7), whose _preserve_unicode_in_replacement keeps the file's typographic characters in unchanged spans. All additions are 1:1 mappings, so the existing position-mapping and preservation logic apply unchanged. Proven live before/after with a multi-line probe; 59 fuzzy-match + 188 file-tools/patch/skill-manager tests pass. --- tests/tools/test_fuzzy_match.py | 52 +++++++++++++++++++++++++++++++++ tools/fuzzy_match.py | 17 +++++++++++ 2 files changed, 69 insertions(+) diff --git a/tests/tools/test_fuzzy_match.py b/tests/tools/test_fuzzy_match.py index 76250569b8b0..62797a0ac8d8 100644 --- a/tests/tools/test_fuzzy_match.py +++ b/tests/tools/test_fuzzy_match.py @@ -320,6 +320,58 @@ class TestUnicodeNormalized: assert new == "plain text there" +class TestUnicodeSpaceAndMinusNormalized: + """Space-separator family + Unicode minus normalization. + + Port of the anomalyco/opencode#38133 patch-matching corpus: files with + typographic spacing (en/em/thin spaces, narrow NBSP, CJK ideographic + space) or the Unicode minus sign must match a model's ASCII old_string + at the precise unicode_normalized strategy — not fall through to the + similarity-based context_aware fallback. + """ + + def test_unicode_minus_matched_and_preserved(self): + content = "offset = value \u2212 1\nprint(offset)\n" + new, count, strategy, err = fuzzy_find_and_replace( + content, "offset = value - 1", "offset = delta - 1" + ) + assert count == 1, f"Expected match, got err={err}" + assert strategy == "unicode_normalized" + # The untouched minus keeps its Unicode form + assert "delta \u2212 1" in new, f"Got {new!r}" + + def test_space_variants_match_at_unicode_strategy(self): + # en space, em space, thin space, narrow NBSP, medium math space, + # ideographic space, figure space, hair space + for space in ["\u2002", "\u2003", "\u2009", "\u202f", + "\u205f", "\u3000", "\u2007", "\u200a"]: + content = f"# wait{space}30{space}seconds\nrun()\n" + new, count, strategy, err = fuzzy_find_and_replace( + content, "# wait 30 seconds", "# wait 60 seconds" + ) + assert count == 1, ( + f"space U+{ord(space):04X}: expected match, err={err}" + ) + assert strategy == "unicode_normalized", ( + f"space U+{ord(space):04X}: matched via {strategy}, " + "expected unicode_normalized" + ) + # Unchanged spaces keep their typographic form; only the + # digits change. + assert f"60{space}seconds" in new, ( + f"space U+{ord(space):04X}: got {new!r}" + ) + + def test_ideographic_space_cjk_line(self): + content = "標題\u3000第一章\nbody text\n" + new, count, strategy, err = fuzzy_find_and_replace( + content, "標題 第一章", "標題 第二章" + ) + assert count == 1, f"Expected match, got err={err}" + assert strategy == "unicode_normalized" + assert "標題\u3000第二章" in new, f"Got {new!r}" + + class TestBlockAnchorThreshold: """Tests for the raised block_anchor threshold (Bug 4).""" diff --git a/tools/fuzzy_match.py b/tools/fuzzy_match.py index 2865411bf367..b5c07aef72ce 100644 --- a/tools/fuzzy_match.py +++ b/tools/fuzzy_match.py @@ -38,6 +38,23 @@ UNICODE_MAP = { "\u2018": "'", "\u2019": "'", # smart single quotes "\u2014": "--", "\u2013": "-", # em/en dashes "\u2026": "...", "\u00a0": " ", # ellipsis and non-breaking space + # Unicode minus sign — models type ASCII '-' for file content that uses + # the typographic minus (math/scientific docs). + "\u2212": "-", + # Space-separator family (Zs) beyond NBSP. Files with typographic + # spacing (en/em/thin spaces, narrow NBSP in French text, ideographic + # space in CJK text) never match a model's ASCII-space old_string via + # the precise strategies, falling through to the similarity-based + # context_aware fallback — which can pick the wrong region and flattens + # the file's Unicode on replacement. (anomalyco/opencode#38133 corpus) + "\u2000": " ", "\u2001": " ", # en/em quad + "\u2002": " ", "\u2003": " ", # en/em space + "\u2004": " ", "\u2005": " ", "\u2006": " ", # three/four/six-per-em + "\u2007": " ", "\u2008": " ", # figure/punctuation space + "\u2009": " ", "\u200a": " ", # thin/hair space + "\u202f": " ", # narrow no-break space + "\u205f": " ", # medium mathematical space + "\u3000": " ", # ideographic (CJK full-width) space } def _unicode_normalize(text: str) -> str: From 53bfe40a35d41a3dbb6bd2d76b2918ce3c9ff5f4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:10:32 -0700 Subject: [PATCH 17/71] fix(errors): classify throttle messages before token-overflow patterns; add new overflow shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from anomalyco/opencode#37848 (+ dev-branch twin #37840): expand context-overflow patterns and guard against rate-limit messages that mention tokens. - 'Throttling error: Too many tokens, please wait before trying again.' (AWS Bedrock / proxy shape) classified as context_overflow and routed a healthy session into compression on every throttle. Added 'throttling' to _RATE_LIMIT_PATTERNS, which the message-only path checks BEFORE the overflow list. - 'Input length N exceeds the maximum allowed input length of M tokens.' (Together/Fireworks shape) fell through to unknown — no compression recovery. Added 'maximum allowed input length' to overflow patterns. - 'request_too_large' / 'Request exceeds the maximum size' (Anthropic 413 type re-wrapped without a status code by aggregators/proxies) fell through to unknown. Added to _PAYLOAD_TOO_LARGE_PATTERNS. All three shapes proven live on main before the fix; 265 classifier + bedrock tests and 238 sibling rate-guard/compression tests pass. --- agent/error_classifier.py | 18 +++++++ tests/agent/test_error_classifier.py | 72 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 33c2f5458560..e629e7b7af9e 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -159,6 +159,14 @@ _RATE_LIMIT_PATTERNS = [ "throttlingexception", "too many concurrent requests", "servicequotaexceededexception", + # Generic throttle prefix — Bedrock (and some proxies) surface throttling + # as "Throttling error: Too many tokens, please wait before trying + # again." Without this entry the message falls through to the + # context-overflow list (which contains "too many tokens") and the retry + # loop compresses a healthy session instead of backing off. Matched + # BEFORE _CONTEXT_OVERFLOW_PATTERNS in the message-only path, so the + # throttle wins. (port of anomalyco/opencode#37848's exclusion guard) + "throttling", ] # Patterns that indicate provider-side overload, NOT a per-credential rate @@ -212,6 +220,12 @@ _PAYLOAD_TOO_LARGE_PATTERNS = [ "request entity too large", "payload too large", "error code: 413", + # Anthropic's structured 413 error type. Normally arrives with an HTTP + # 413 status (handled by the status path), but aggregators/proxies can + # re-wrap it into a plain message with no status attribute — route it to + # the same compression recovery. (port of anomalyco/opencode#37848) + "request_too_large", + "request exceeds the maximum size", ] # Image-size patterns. Matched against 400 bodies (not 413) because most @@ -298,6 +312,10 @@ _CONTEXT_OVERFLOW_PATTERNS = [ "max input token", "input token", "exceeds the maximum number of input tokens", + # Together/Fireworks-style: "Input length 131393 exceeds the maximum + # allowed input length of 131040 tokens." No other pattern in this list + # matches that wording. (port of anomalyco/opencode#37848) + "maximum allowed input length", ] # Model not found patterns diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index 8ef407f50fb2..363462ee1d2c 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -2170,3 +2170,75 @@ class Test408RequestTimeout: assert result.should_fallback is True assert result.should_compress is False + +# ── Test: throttle vs overflow disambiguation + new overflow shapes ───── +# Port of anomalyco/opencode#37848 (expand context overflow patterns + +# rate-limit exclusion guard). + +class TestThrottleVsOverflowDisambiguation: + """Throttle messages that mention tokens must NOT route to compression.""" + + def test_bedrock_throttling_too_many_tokens_is_rate_limit(self): + # AWS Bedrock (and some proxies) surface throttling as + # "Throttling error: Too many tokens, please wait before trying + # again." — the "too many tokens" fragment sits in + # _CONTEXT_OVERFLOW_PATTERNS, so before the "throttling" rate-limit + # pattern this compressed a healthy session on every throttle. + e = Exception( + "Throttling error: Too many tokens, please wait before trying again." + ) + result = classify_api_error(e, provider="bedrock", model="claude") + assert result.reason == FailoverReason.rate_limit + assert result.should_compress is False + + def test_plain_too_many_tokens_still_overflow(self): + # Without any throttle wording, "Too many tokens" remains a + # context-overflow signal (Z.AI / GLM family wording). + e = Exception("Too many tokens") + result = classify_api_error(e, provider="zai", model="glm-5") + assert result.reason == FailoverReason.context_overflow + assert result.should_compress is True + + +class TestExpandedOverflowPatterns: + """New provider overflow wordings route into compression recovery.""" + + def test_maximum_allowed_input_length_is_overflow(self): + # Together/Fireworks-style wording — matched no pattern before. + e = Exception( + "Input length 131393 exceeds the maximum allowed input length " + "of 131040 tokens." + ) + result = classify_api_error(e, provider="together", model="m") + assert result.reason == FailoverReason.context_overflow + assert result.should_compress is True + + def test_request_too_large_message_only_is_payload_too_large(self): + # Anthropic's structured 413 type re-wrapped by a proxy with no + # status attribute — was falling through to `unknown`. + e = Exception( + '{"error":{"type":"request_too_large",' + '"message":"Request exceeds the maximum size"}}' + ) + result = classify_api_error(e, provider="anthropic", model="m") + assert result.reason == FailoverReason.payload_too_large + assert result.should_compress is True + + def test_longer_than_context_length_still_overflow(self): + # Regression guard for wordings that already matched. + e = Exception( + "The input (516368 tokens) is longer than the model's context " + "length (262144 tokens)." + ) + result = classify_api_error(e, provider="openrouter", model="m") + assert result.reason == FailoverReason.context_overflow + + def test_configured_context_size_still_overflow(self): + e = Exception( + "Prompt has 5,958,968 tokens, but the configured context size " + "is 256,000 tokens" + ) + result = classify_api_error(e, provider="ollama", model="m") + assert result.reason == FailoverReason.context_overflow + + From da26ff986bf4a64b0aea92559ed763809488ef1f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:13:03 -0700 Subject: [PATCH 18/71] fix(approval): detect recursive rm when flags follow operands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from openai/codex#33464: GNU rm permutes options, so `rm build/ -rf`, `rm build/ -r -f`, and `rm build/ --recursive --force` are equivalent to the flags-first spellings — but every existing rm pattern required the flag group BEFORE the path, so these spellings ran with no approval prompt at all (proven live on main). The hardline floor was NOT affected: protected paths (/, system dirs, $HOME) match regardless of flag position because the hardline path matcher does not require flags. The gap was the approval-prompt layer for arbitrary paths. New DANGEROUS_PATTERNS entry with a tempered operand run: cannot cross command separators (; | & newline), quotes, or a bare -- end-of-options separator (after --, -rf is a literal filename). Flag token must be whitespace-anchored so the r inside long options like --registry does not count. 9 positive + 7 negative shapes in tests; approval cluster (868 tests) green. --- tests/tools/test_approval.py | 35 +++++++++++++++++++++++++++++++++++ tools/approval.py | 18 ++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index 1ebb3efe6d98..d79ec64e786f 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -119,6 +119,41 @@ class TestDetectDangerousRm: assert key is not None assert "delete" in desc.lower() + def test_rm_flags_after_operands_detected(self): + # GNU rm permutes options: `rm build/ -rf` == `rm -rf build/`. + # Port of openai/codex#33464. + for cmd in ( + "rm build/ -rf", + "rm build/ -r -f", + "rm build/ -fR", + "rm build/ --recursive --force", + "rm build/ --force --recursive", + "rm ~/projects -rf", + "sudo rm build/ -rf", + "rm -f build/ -r", + "rm one two three -rf", + ): + is_dangerous, key, desc = detect_dangerous_command(cmd) + assert is_dangerous is True, f"{cmd!r} should require approval" + assert "delete" in desc.lower() + + def test_rm_flags_after_operands_no_false_positives(self): + for cmd in ( + # after a bare `--`, -rf-looking tokens are literal filenames + "rm -- -weird-r-file", + "rm -f -- -r-file", + # a later pipeline/command segment's flags don't belong to rm + "rm foo | grep -r bar", + "rm foo; ls -lart", + # long options whose `r` is not whitespace-anchored + "npm rm somepkg --registry=https://registry.npmjs.org", + "rm old.log --verbose", + # plain multi-operand deletes stay safe + "rm build/file.txt other.txt", + ): + is_dangerous, key, desc = detect_dangerous_command(cmd) + assert is_dangerous is False, f"{cmd!r} should be safe, got: {desc}" + def test_nonrecursive_verification_artifact_cleanup_is_not_dangerous(self): with mock_patch("tempfile.gettempdir", return_value="/tmp"): for prefix in ("hermes-verify-", "hermes-ad-hoc-"): diff --git a/tools/approval.py b/tools/approval.py index 4b27bfdfb002..24c08f76916a 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -607,6 +607,24 @@ DANGEROUS_PATTERNS = [ (r'\brm\s+(-[^\s]*\s+)*/', "delete in root path"), (r'\brm\s+-[^\s]*r', "recursive delete"), (r'\brm\s+--recursive\b', "recursive delete (long flag)"), + # GNU rm permutes options, so a recursive flag group may legally FOLLOW + # the operands: `rm build/ -rf`, `rm build/ -r -f`, and `rm build/ + # --recursive --force` are all equivalent to the flags-first spellings the + # two patterns above catch — without this rule they run with no approval + # prompt at all. The operand run is tempered: it cannot cross a command + # separator (`;`, `|`, `&`, newline — so a later pipeline segment's flags, + # e.g. `rm foo | grep -r bar`, are not attributed to `rm`), cannot cross a + # quote (so `git commit -m "rm x" --amend` style data can't bridge an `rm` + # word to an unrelated dash token), and cannot cross a bare ` -- ` + # end-of-options separator (after `--`, POSIX rm treats `-rf` as a literal + # filename, not flags; guarded both leading and mid-run). The flag token + # itself must start right after whitespace so the `r` inside long options + # like `--registry` (preceded by `-`, not whitespace) does not count. + # Port of openai/codex#33464 ("recognize force options when they follow + # operands"). + (r'\brm\s+(?!--(?:\s|$))(?:(?!\s--(?:\s|$))[^\n"\';|&])*\s' + r'(?:-[a-z]*r[a-z]*\b|--recursive\b)', + "recursive delete (flags after operands)"), # Windows shell front-ends have destructive built-ins that do not look like # Unix `rm`. Gate only when they are executed through cmd/powershell so # ordinary prose or filenames containing "del"/"rd" do not trip the guard. From d4381f0e391c1df8fe757f6c362dc24b9d5bc35e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:11:18 -0700 Subject: [PATCH 19/71] fix(gemini): explain legacy Standard-key 401 rejections with migration guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from Kilo-Org/kilocode#12162. Google began rejecting unrestricted legacy 'Standard' Google Cloud API keys on the Gemini API on June 19, 2026 (all Standard keys stop working in September 2026). The rejection is a 401 whose message misleadingly tells the user to supply an OAuth 2 access token. gemini_http_error() now appends actionable guidance (check key type in AI Studio, mint a new Gemini API key, temporary restriction bridge) on that narrow shape — matched via google.rpc.ErrorInfo reason ACCESS_TOKEN_TYPE_UNSUPPORTED or the 'Expected OAuth 2 access token' signature. Plain invalid keys (API_KEY_INVALID) keep their existing message. Also fixes a latent sibling gap: _summarize_api_error() preferred re-extracting the raw response body for errors carrying .response, which stripped adapter- composed guidance (this one AND the existing free-tier 429 guidance) from the user-facing summary. GeminiAPIError now surfaces its composed message. --- agent/gemini_native_adapter.py | 42 ++++ run_agent.py | 7 + .../test_gemini_standard_key_guidance.py | 191 ++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 tests/agent/test_gemini_standard_key_guidance.py diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index b4f6e6386e7d..bb53e32b2cec 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -163,6 +163,42 @@ _FREE_TIER_GUIDANCE = ( ) +def is_standard_key_auth_error( + status: int, error_message: str, reason: str = "" +) -> bool: + """Return True when a Gemini 401 indicates Google rejected the key TYPE. + + Google began rejecting unrestricted legacy "Standard" Google Cloud API + keys on the Gemini API on June 19, 2026, and ALL Standard keys stop + working in September 2026. The rejection surfaces as a misleading 401 + telling the user to supply an OAuth 2 access token ("Request had invalid + authentication credentials. Expected OAuth 2 access token, login cookie + or other valid authentication credential."), optionally carrying + ``google.rpc.ErrorInfo`` reason ``ACCESS_TOKEN_TYPE_UNSUPPORTED``. + + Scoped narrowly so a plain bad key (reason ``API_KEY_INVALID``, + "API key not valid") keeps its existing message. + """ + if status != 401: + return False + if reason == "ACCESS_TOKEN_TYPE_UNSUPPORTED": + return True + return "expected oauth 2 access token" in (error_message or "").lower() + + +_STANDARD_KEY_GUIDANCE = ( + "\n\nGoogle Gemini rejected this API key's type — you do NOT need OAuth. " + "Google began rejecting legacy 'Standard' Google Cloud keys for the " + "Gemini API on June 19, 2026, and all Standard keys stop working in " + "September 2026. Open https://aistudio.google.com/api-keys, check the " + "key's type and status, and create a replacement Gemini API key (or, as " + "a temporary bridge, restrict the Standard key to " + "generativelanguage.googleapis.com). Then update GEMINI_API_KEY / " + "GOOGLE_API_KEY in ~/.hermes/.env and restart your session. " + "Details: https://ai.google.dev/gemini-api/docs/api-key" +) + + class GeminiAPIError(Exception): """Error shape compatible with Hermes retry/error classification.""" @@ -824,6 +860,12 @@ def gemini_http_error( if status == 429 and is_free_tier_quota_error(err_message or body_text): message = message + _FREE_TIER_GUIDANCE + # Legacy "Standard" Google Cloud key rejection (June 19, 2026 onward) -> + # Google's raw 401 misleadingly tells the user to use OAuth. Append the + # actual fix (mint a new Gemini API key in AI Studio). + if is_standard_key_auth_error(status, err_message or body_text, reason): + message = message + _STANDARD_KEY_GUIDANCE + return GeminiAPIError( message, code=code, diff --git a/run_agent.py b/run_agent.py index 166ce7dfb97e..40a1cdfe700f 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2367,6 +2367,13 @@ class AIAgent: parts.append(f"Ray {ray_id}") return " — ".join(parts) + # GeminiAPIError (agent/gemini_native_adapter.py) already composes a + # clean one-liner and may have appended actionable guidance (free-tier + # 429, legacy Standard-key 401). Prefer its message over re-extracting + # the raw response body below, which would strip that guidance. + if type(error).__name__ == "GeminiAPIError": + return redact_sensitive_text(raw[:1000]) + # JSON body errors from OpenAI/Anthropic SDKs body = getattr(error, "body", None) if isinstance(body, dict): diff --git a/tests/agent/test_gemini_standard_key_guidance.py b/tests/agent/test_gemini_standard_key_guidance.py new file mode 100644 index 000000000000..1f50f1af079b --- /dev/null +++ b/tests/agent/test_gemini_standard_key_guidance.py @@ -0,0 +1,191 @@ +"""Tests for Gemini legacy Standard-key 401 guidance. + +Google began rejecting unrestricted legacy "Standard" Google Cloud API keys +on the Gemini API on June 19, 2026 (all Standard keys stop working in +September 2026). The rejection is a 401 whose message misleadingly tells the +user to supply an OAuth 2 access token. ``gemini_http_error`` must append +actionable key-migration guidance on that shape — and ONLY that shape. + +Port of Kilo-Org/kilocode#12162, adapted to Hermes' GeminiAPIError surface. +""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +from agent.gemini_native_adapter import ( + gemini_http_error, + is_standard_key_auth_error, +) + + +GOOGLE_AUTH_MESSAGE = ( + "Request had invalid authentication credentials. Expected OAuth 2 access " + "token, login cookie or other valid authentication credential. See " + "https://developers.google.com/identity/sign-in/web/devconsole-project." +) + +GUIDANCE_MARKER = "rejected this API key's type" + + +def _mock_response(status: int, body: str, headers: dict | None = None) -> MagicMock: + resp = MagicMock() + resp.status_code = status + resp.headers = headers or {} + resp.text = body + return resp + + +def _google_error_body( + status_code: int, + message: str, + status: str = "UNAUTHENTICATED", + reason: str | None = None, +) -> str: + err: dict = {"code": status_code, "message": message, "status": status} + if reason is not None: + err["details"] = [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": reason, + "domain": "googleapis.com", + "metadata": {"service": "generativelanguage.googleapis.com"}, + } + ] + return json.dumps({"error": err}) + + +class TestIsStandardKeyAuthError: + def test_matches_oauth_message_without_reason(self): + assert is_standard_key_auth_error(401, GOOGLE_AUTH_MESSAGE) + + def test_matches_error_info_reason_alone(self): + assert is_standard_key_auth_error( + 401, "some other text", "ACCESS_TOKEN_TYPE_UNSUPPORTED" + ) + + def test_rejects_non_401_status(self): + assert not is_standard_key_auth_error(400, GOOGLE_AUTH_MESSAGE) + assert not is_standard_key_auth_error(403, GOOGLE_AUTH_MESSAGE) + + def test_rejects_plain_invalid_key(self): + assert not is_standard_key_auth_error( + 401, "API key not valid. Please pass a valid API key.", "API_KEY_INVALID" + ) + + def test_empty_message_is_safe(self): + assert not is_standard_key_auth_error(401, "") + assert not is_standard_key_auth_error(401, None) # type: ignore[arg-type] + + +class TestGeminiHttpErrorGuidance: + def test_guidance_appended_on_oauth_401_with_reason(self): + body = _google_error_body( + 401, GOOGLE_AUTH_MESSAGE, reason="ACCESS_TOKEN_TYPE_UNSUPPORTED" + ) + err = gemini_http_error(_mock_response(401, body)) + text = str(err) + assert GUIDANCE_MARKER in text + assert "aistudio.google.com/api-keys" in text + assert "ai.google.dev/gemini-api/docs/api-key" in text + assert err.code == "gemini_unauthorized" + + def test_guidance_appended_on_oauth_401_without_reason(self): + body = _google_error_body(401, GOOGLE_AUTH_MESSAGE) + err = gemini_http_error(_mock_response(401, body)) + assert GUIDANCE_MARKER in str(err) + + def test_original_google_message_preserved(self): + body = _google_error_body(401, GOOGLE_AUTH_MESSAGE) + err = gemini_http_error(_mock_response(401, body)) + assert "Expected OAuth 2 access token" in str(err) + + def test_plain_invalid_key_401_gets_no_guidance(self): + body = _google_error_body( + 401, + "API key not valid. Please pass a valid API key.", + reason="API_KEY_INVALID", + ) + err = gemini_http_error(_mock_response(401, body)) + assert GUIDANCE_MARKER not in str(err) + + def test_403_with_oauth_message_gets_no_guidance(self): + body = _google_error_body(403, GOOGLE_AUTH_MESSAGE, status="PERMISSION_DENIED") + err = gemini_http_error(_mock_response(403, body)) + assert GUIDANCE_MARKER not in str(err) + + def test_free_tier_429_unaffected(self): + body = json.dumps( + { + "error": { + "code": 429, + "message": ( + "Quota exceeded for metric: generativelanguage.googleapis.com/" + "generate_content_free_tier_requests, limit: 20" + ), + } + } + ) + err = gemini_http_error(_mock_response(429, body)) + text = str(err) + assert "free tier" in text + assert GUIDANCE_MARKER not in text + + def test_unparseable_401_body_with_oauth_text_still_matches(self): + # err_message empty -> falls back to raw body_text scan. + err = gemini_http_error(_mock_response(401, GOOGLE_AUTH_MESSAGE)) + assert GUIDANCE_MARKER in str(err) + + +class TestSummarizerPreservesGuidance: + """_summarize_api_error must not strip adapter-composed guidance. + + GeminiAPIError carries ``.response``; without the GeminiAPIError branch, + the summarizer re-extracts the raw body's error.message (capped at 300 + chars), silently discarding both the Standard-key 401 guidance and the + pre-existing free-tier 429 guidance. + """ + + def test_standard_key_guidance_survives_summarizer(self): + from run_agent import AIAgent + + body = _google_error_body( + 401, GOOGLE_AUTH_MESSAGE, reason="ACCESS_TOKEN_TYPE_UNSUPPORTED" + ) + err = gemini_http_error(_mock_response(401, body)) + summary = AIAgent._summarize_api_error(err) + assert GUIDANCE_MARKER in summary + assert "aistudio.google.com/api-keys" in summary + + def test_free_tier_guidance_survives_summarizer(self): + from run_agent import AIAgent + + body = json.dumps( + { + "error": { + "code": 429, + "message": ( + "Quota exceeded for metric: " + "generativelanguage.googleapis.com/" + "generate_content_free_tier_requests, limit: 20" + ), + } + } + ) + err = gemini_http_error(_mock_response(429, body)) + summary = AIAgent._summarize_api_error(err) + assert "free tier" in summary + + def test_non_gemini_errors_keep_response_body_extraction(self): + from types import SimpleNamespace + + from run_agent import AIAgent + + err = Exception("") + err.status_code = 400 + err.body = {} + err.response = SimpleNamespace( + text='{"error": {"message": "model `foo` does not exist"}}' + ) + summary = AIAgent._summarize_api_error(err) + assert "model `foo` does not exist" in summary From 1e239f724bb27298f0bb54708f54e70700e9ba5b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:44:51 -0700 Subject: [PATCH 20/71] chore: map contributor ruslanvasylev for #68056 salvage --- contributors/emails/ruslan.vasylev.vfx@gmail.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/ruslan.vasylev.vfx@gmail.com diff --git a/contributors/emails/ruslan.vasylev.vfx@gmail.com b/contributors/emails/ruslan.vasylev.vfx@gmail.com new file mode 100644 index 000000000000..fe69d33fb052 --- /dev/null +++ b/contributors/emails/ruslan.vasylev.vfx@gmail.com @@ -0,0 +1 @@ +ruslanvasylev From 474c84ed8d209cf38b0f9d55ebc3c22c2d050366 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:18:34 -0700 Subject: [PATCH 21/71] fix(agent): uniquify duplicate tool-call ids to keep call/result pairing lossless MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from openclaw/openclaw#110518 / #110956: some models reuse one call id for different tool calls in a single batch (native Kimi Responses replays, Ollama-compatible endpoints, degraded models at long context). Hermes kept both calls but the pre-API sanitizer then dropped the later call/result pair per id (#58327), so the second call's output silently vanished from every replayed payload — the model never saw it and confabulated. _uniquify_tool_call_ids renames later collisions to a deterministic <id>_d<n> suffix at ingestion, before validation/dispatch/history build, so both pairs survive. Composite Responses ids collide on the call half and keep their response-item half. Deterministic suffixes preserve prompt-cache prefix stability (no random UUIDs). --- agent/conversation_loop.py | 8 +++ run_agent.py | 77 +++++++++++++++++++++ tests/run_agent/test_agent_guardrails.py | 88 ++++++++++++++++++++++++ 3 files changed, 173 insertions(+) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 1f72873edc10..9eb3fdad4b04 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -5469,6 +5469,14 @@ def run_conversation( args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200] logging.debug("Tool call: %s with args: %s...", tc.function.name, args_preview) + # Uniquify duplicate tool-call ids BEFORE any downstream + # consumer (validation error paths, dispatch, history build, + # Responses item-id derivation). Models that reuse one id for + # different calls in a batch otherwise lose the later call's + # result: the pre-API sanitizer keeps only the first + # call/result pair per id. See _uniquify_tool_call_ids. + agent._uniquify_tool_call_ids(assistant_message.tool_calls) + # Validate tool call names - detect model hallucinations # Repair mismatched tool names before validating for tc in assistant_message.tool_calls: diff --git a/run_agent.py b/run_agent.py index 40a1cdfe700f..21e550386106 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4224,6 +4224,83 @@ class AIAgent: logger.warning("Removed duplicate tool call: %s", tc.function.name) return unique if len(unique) < len(tool_calls) else tool_calls + @staticmethod + def _uniquify_tool_call_ids(tool_calls: list) -> list: + """Ensure every tool call in a single assistant turn has a distinct id. + + Some models/providers reuse one call id across different calls in a + single batch (observed with native Kimi Responses replays, Ollama- + compatible endpoints, and degraded models at long context; same bug + class as openclaw/openclaw#110518 / #110956). Duplicate ids are lossy + downstream: the pre-API sanitizer keeps only the first call/result + pair per id (#58327), so the later call's result silently vanishes + from every replayed payload, and strict providers (Anthropic + tool_use, DeepSeek) reject duplicate ids outright. + + The first occurrence keeps its id; later collisions get a + deterministic ``<id>_d<n>`` suffix — never a random UUID, which would + break prompt-cache prefix stability across replays. Mutates the + entries in place (SDK models / SimpleNamespace / dicts) and returns + the same list. Blank/missing ids are left for the deterministic + fallback in ``build_assistant_message``. + """ + seen: set = set() + for tc in tool_calls or []: + if isinstance(tc, dict): + raw = tc.get("call_id") or tc.get("id") or "" + else: + raw = getattr(tc, "call_id", None) or getattr(tc, "id", None) or "" + raw = raw.strip() if isinstance(raw, str) else "" + if not raw: + continue + # Composite Responses ids ("call_x|fc_y") collide on the call + # half — that's the pairing key providers enforce per turn. + cid = raw.split("|", 1)[0] + if not cid: + continue + if cid not in seen: + seen.add(cid) + continue + n = 2 + new_id = f"{cid}_d{n}" + while new_id in seen: + n += 1 + new_id = f"{cid}_d{n}" + seen.add(new_id) + + def _renamed(value): + # Preserve a composite id's response-item half so the + # provider's real fc_/item id survives the rename. + if isinstance(value, str) and "|" in value: + return f"{new_id}|{value.split('|', 1)[1]}" + return new_id + + try: + if isinstance(tc, dict): + if tc.get("id"): + tc["id"] = _renamed(tc["id"]) + else: + tc["id"] = new_id + if tc.get("call_id"): + tc["call_id"] = new_id + else: + tc.id = _renamed(getattr(tc, "id", None)) + if getattr(tc, "call_id", None): + tc.call_id = new_id + except Exception: + logger.warning( + "Could not uniquify duplicate tool call id %s", cid + ) + continue + _fn = tc.get("function") if isinstance(tc, dict) else getattr(tc, "function", None) + _fn_name = (_fn.get("name") if isinstance(_fn, dict) else getattr(_fn, "name", None)) or "?" + logger.warning( + "Model reused tool call id %s within one turn; renamed the " + "duplicate to %s (tool=%s) to keep call/result pairing " + "lossless.", cid, new_id, _fn_name, + ) + return tool_calls + def _repair_tool_call(self, tool_name: str) -> str | None: """Forwarder — see ``agent.agent_runtime_helpers.repair_tool_call``.""" from agent.agent_runtime_helpers import repair_tool_call diff --git a/tests/run_agent/test_agent_guardrails.py b/tests/run_agent/test_agent_guardrails.py index bcec4e3d7c8f..9bf565fd6308 100644 --- a/tests/run_agent/test_agent_guardrails.py +++ b/tests/run_agent/test_agent_guardrails.py @@ -4,6 +4,7 @@ Covers three static methods on AIAgent (inspired by PR #1321 — @alireza78a): - _sanitize_api_messages() — Phase 1: orphaned tool pair repair - _cap_delegate_task_calls() — Phase 2a: subagent concurrency limit - _deduplicate_tool_calls() — Phase 2b: identical call deduplication + - _uniquify_tool_call_ids() — Phase 2c: duplicate-id repair (lossless pairing) """ import types @@ -277,6 +278,93 @@ class TestDeduplicateToolCalls: assert len(tcs) == original_len +# --------------------------------------------------------------------------- +# Phase 2c — _uniquify_tool_call_ids +# --------------------------------------------------------------------------- + +def make_tc_id(id_: str, name: str, arguments: str = "{}", call_id=None) -> types.SimpleNamespace: + tc = types.SimpleNamespace() + tc.id = id_ + if call_id is not None: + tc.call_id = call_id + tc.function = types.SimpleNamespace(name=name, arguments=arguments) + return tc + + +class TestUniquifyToolCallIds: + + def test_distinct_calls_sharing_id_get_unique_ids(self): + tcs = [ + make_tc_id("call_1", "read_file", '{"path":"a.txt"}'), + make_tc_id("call_1", "read_file", '{"path":"b.txt"}'), + ] + out = AIAgent._uniquify_tool_call_ids(tcs) + assert out is tcs + assert tcs[0].id == "call_1" # first occurrence untouched + assert tcs[1].id == "call_1_d2" # deterministic suffix + assert len({tc.id for tc in tcs}) == 2 + + def test_three_way_collision(self): + tcs = [make_tc_id("x", "t", '{"a":1}'), + make_tc_id("x", "t", '{"a":2}'), + make_tc_id("x", "t", '{"a":3}')] + AIAgent._uniquify_tool_call_ids(tcs) + assert [tc.id for tc in tcs] == ["x", "x_d2", "x_d3"] + + def test_suffix_collision_with_existing_id_avoided(self): + # A real id "x_d2" already exists — the rename must skip past it. + tcs = [make_tc_id("x", "t", '{"a":1}'), + make_tc_id("x_d2", "t", '{"a":2}'), + make_tc_id("x", "t", '{"a":3}')] + AIAgent._uniquify_tool_call_ids(tcs) + assert [tc.id for tc in tcs] == ["x", "x_d2", "x_d3"] + + def test_unique_ids_untouched(self): + tcs = [make_tc_id("a", "t1"), make_tc_id("b", "t2")] + AIAgent._uniquify_tool_call_ids(tcs) + assert [tc.id for tc in tcs] == ["a", "b"] + + def test_blank_and_missing_ids_left_for_fallback(self): + tc_blank = make_tc_id("", "t1") + tc_none = make_tc_id(None, "t2") + tc_noattr = make_tc("t3") # no .id attribute at all + AIAgent._uniquify_tool_call_ids([tc_blank, tc_none, tc_noattr]) + assert tc_blank.id == "" + assert tc_none.id is None + assert not hasattr(tc_noattr, "id") + + def test_call_id_sibling_kept_consistent(self): + # Responses-path objects carry call_id; build_assistant_message + # prefers it, so the rename must update both. + tcs = [make_tc_id("call_1", "t", '{"a":1}', call_id="call_1"), + make_tc_id("call_1", "t", '{"a":2}', call_id="call_1")] + AIAgent._uniquify_tool_call_ids(tcs) + assert tcs[1].id == "call_1_d2" + assert tcs[1].call_id == "call_1_d2" + assert tcs[0].call_id == "call_1" + + def test_dict_entries_supported(self): + tcs = [{"id": "c", "function": {"name": "t", "arguments": '{"a":1}'}}, + {"id": "c", "call_id": "c", "function": {"name": "t", "arguments": '{"a":2}'}}] + AIAgent._uniquify_tool_call_ids(tcs) + assert tcs[0]["id"] == "c" + assert tcs[1]["id"] == "c_d2" + assert tcs[1]["call_id"] == "c_d2" + + def test_empty_and_none_safe(self): + assert AIAgent._uniquify_tool_call_ids([]) == [] + assert AIAgent._uniquify_tool_call_ids(None) is None + + def test_composite_responses_ids_collide_on_call_half(self): + # "call_x|fc_y" composites pair on the call half; the rename must + # keep the provider's response-item half intact. + tcs = [make_tc_id("call_x|fc_1", "t", '{"a":1}'), + make_tc_id("call_x|fc_2", "t", '{"a":2}')] + AIAgent._uniquify_tool_call_ids(tcs) + assert tcs[0].id == "call_x|fc_1" + assert tcs[1].id == "call_x_d2|fc_2" + + # --------------------------------------------------------------------------- # _get_tool_call_id_static # --------------------------------------------------------------------------- From b41eee450be49dee3113170e7c14420e77c6964e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:16:00 -0700 Subject: [PATCH 22/71] fix(redact): stop masking prose words that embed a secret keyword (Secretary, tokenizer, author=) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from nearai/ironclaw#6129: their sensitive-marker scrubber matched markers as bare substrings, so tool results containing 'Secretary of the Treasury' were scrubbed as 'secret' on replay, evicting legitimate content and forcing the model into a re-fetch loop. Hermes' lowercase/dotted/YAML config-key redaction patterns (_CFG_DOTTED_RE, _CFG_ANCHORED_RE, _YAML_ASSIGN_RE) had the same false-positive class: their key classes allow arbitrary alphanumeric affixes around the keyword, so ordinary document text like 'Secretary: J.Smith', 'tokenizer: cl100k_base' (HF model cards), and BibTeX 'author=Smith' got value-masked on the surfaces that run these passes (browser snapshots, log lines, kanban summaries, CLI-echoed output). Fix: post-match word-boundary validation of the keyword occurrence inside the matched key. Boundaries: key edges, non-letters (_ - . digits), camelCase transitions (clientSecret, secretKey, APIToken), plural 's' (secrets:, tokens:). Concatenated real-world compounds keep matching via explicit alternatives (authtoken, authkey, secretkey, accesstoken). ALL-CAPS keys keep legacy embedded matching (MYTOKEN=...) — all-caps is almost never prose, same rationale as _ENV_ASSIGN_RE. Same discipline the file already applies to exact-match body/query keys (ported from ironclaw#2529) and the deliberate 'auth' exclusion that keeps 'author:' from matching. --- agent/redact.py | 91 +++++++++++++++++++++++++++++++++++++ tests/agent/test_redact.py | 93 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/agent/redact.py b/agent/redact.py index ebca1ae75f14..bff23934da6c 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -175,6 +175,85 @@ _YAML_ASSIGN_RE = re.compile( re.IGNORECASE | re.MULTILINE, ) +# Word-boundary validation for the mixed/lowercase key patterns above +# (_CFG_DOTTED_RE, _CFG_ANCHORED_RE, _YAML_ASSIGN_RE). +# +# Those key classes allow arbitrary alphanumeric affixes around the secret +# keyword so real key names like ``client_secret``, ``clientSecret``, and +# ``s3.secret-key`` match. The side effect: ordinary prose/document words that +# merely CONTAIN a keyword also matched — ``Secretary: J.Smith`` (secret), +# ``tokenizer: cl100k_base`` (token), ``author=Smith`` (auth) — mangling +# legitimate content on the surfaces that run these passes (browser snapshots, +# log lines, kanban summaries, CLI-echoed command output). Ported from +# nearai/ironclaw#6129, where the same substring false positive ("Secretary of +# the Treasury" matching the ``secret`` marker) scrubbed legitimate tool +# results from the replayed transcript and sent the model into a re-fetch +# loop. +# +# A keyword occurrence only counts when it sits at a word boundary within the +# key: at the key's edge, next to a non-letter (``_ - . 3``), or at a +# camelCase transition (``clientSecret``, ``secretKey``, ``APIToken``). A +# trailing plural ``s`` is treated as part of the keyword (``secrets:``, +# ``tokens:``). Common concatenated compounds keep matching via explicit +# alternatives (``authtoken`` ngrok, ``authkey`` tailscale, ``secretkey`` +# minio, ``apikey``). Embedded occurrences inside a larger word +# (``secretary``, ``tokenizer``, ``authored``, ``credentialing``) no longer +# match. ALL-CAPS keys keep the legacy embedded matching (``MYTOKEN=…``) — an +# all-caps key is almost never prose, the same rationale as _ENV_ASSIGN_RE. +_KEY_KEYWORD_RE = re.compile( + r"(?:api|auth|access|refresh|session|secret)[ _.\-]?(?:key|token)" + r"|token|secret|passwd|password|credential|auth", + re.IGNORECASE, +) + + +def _is_word_start(s: str, i: int) -> bool: + """True if position ``i`` in ``s`` begins a word (not mid-word).""" + if i == 0: + return True + prev, cur = s[i - 1], s[i] + if not prev.isalpha(): + return True + if cur.isupper() and prev.islower(): + return True # camelCase: clientSecret + # Acronym run ending: APIToken — the 'T' begins a new word when it is + # followed by lowercase while the preceding run is uppercase. + if cur.isupper() and prev.isupper() and i + 1 < len(s) and s[i + 1].islower(): + return True + return False + + +def _is_word_end(s: str, j: int, *, allow_plural: bool = True) -> bool: + """True if position ``j`` (exclusive end) in ``s`` ends a word.""" + if j >= len(s): + return True + cur = s[j] + if not cur.isalpha(): + return True + if cur.isupper() and s[j - 1].islower(): + return True # camelCase continuation: secretKey + if allow_plural and cur in "sS": + return _is_word_end(s, j + 1, allow_plural=False) + return False + + +def _key_has_secret_keyword(key: str) -> bool: + """True if ``key`` contains a secret keyword at a word boundary. + + Post-match validator for _CFG_DOTTED_RE / _CFG_ANCHORED_RE / + _YAML_ASSIGN_RE hits — rejects prose words that merely embed a keyword + (``secretary``, ``tokenizer``, ``authored``). Safe to call with the + _ENV_ASSIGN_RE key too: all-caps keys short-circuit to the legacy + embedded-match behavior. + """ + letters = [c for c in key if c.isalpha()] + if letters and all(c.isupper() for c in letters): + return True # legacy all-caps behavior (MYTOKEN=…) + for m in _KEY_KEYWORD_RE.finditer(key): + if _is_word_start(key, m.start()) and _is_word_end(key, m.end()): + return True + return False + # JSON field patterns: "apiKey": "value", "token": "value", etc. _JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)" _JSON_FIELD_RE = re.compile( @@ -614,6 +693,13 @@ def redact_sensitive_text( # prose/log contexts (issue #2852): ``KEY=os.getenv('X')``. if _ENV_LOOKUP_VALUE_RE.match(value): return m.group(0) + # Keyword must sit at a word boundary within the key — + # ``author=Smith`` / ``press.secretary=…`` are prose, not + # credentials (ported from nearai/ironclaw#6129). All-caps + # keys (the _ENV_ASSIGN_RE shape) short-circuit to legacy + # embedded matching inside the helper. + if not _key_has_secret_keyword(name): + return m.group(0) return f"{name}={quote}{_mask_token(value)}{quote}" text = _ENV_ASSIGN_RE.sub(_redact_env, text) # Lowercase/dotted config keys (issue #16413). Skip URLs entirely — @@ -647,6 +733,11 @@ def redact_sensitive_text( # not a leaked secret value. if _ENV_LOOKUP_VALUE_RE.match(value): return m.group(0) + # Keyword must sit at a word boundary within the key — + # ``Secretary: J.Smith`` / ``tokenizer: cl100k_base`` are + # document text, not credentials (nearai/ironclaw#6129). + if not _key_has_secret_keyword(key): + return m.group(0) return f"{key}{sep}{_mask_token(value)}" text = _YAML_ASSIGN_RE.sub(_redact_yaml, text) diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py index 066834b634ff..71806ba05cac 100644 --- a/tests/agent/test_redact.py +++ b/tests/agent/test_redact.py @@ -1097,3 +1097,96 @@ class TestRedactCdpUrl: def test_none_returns_empty(self): assert redact_cdp_url(None) == "" + + +class TestKeywordWordBoundary: + """Ported from nearai/ironclaw#6129 — a secret keyword embedded inside a + larger prose word (``Secretary`` ⊃ ``secret``, ``tokenizer`` ⊃ ``token``, + ``authored`` ⊃ ``auth``) must NOT trigger the lowercase/dotted/YAML config + passes. Real key shapes (separators, camelCase, acronyms, plurals, common + concatenated compounds, all-caps env style) must keep redacting. + """ + + # ── prose words embedding a keyword are preserved ────────────────── + + def test_secretary_yaml_value_preserved(self): + text = "Secretary: JanetYellen1234567890" + assert redact_sensitive_text(text) == text + + def test_undersecretary_preserved(self): + text = "Undersecretary: RobertSmith123456789" + assert redact_sensitive_text(text) == text + + def test_tokenizer_yaml_value_preserved(self): + # HuggingFace model-card style metadata. + text = "tokenizer: cl100k_base_long_name_x" + assert redact_sensitive_text(text) == text + + def test_secretariat_preserved(self): + text = "secretariat: GenevaOffice123456789" + assert redact_sensitive_text(text) == text + + def test_secretary_equals_assignment_preserved(self): + text = "secretary=JohnSmith12345678901234" + assert redact_sensitive_text(text) == text + + def test_dotted_secretary_preserved(self): + text = "press.secretary=KarineJeanPierre123" + assert redact_sensitive_text(text) == text + + def test_bibtex_author_assignment_preserved(self): + # ``author`` embeds the ``auth`` keyword — citation keys are prose. + text = "author=Smith2020LongCitationKey1" + assert redact_sensitive_text(text) == text + + def test_credentialing_preserved(self): + text = "credentialing=enabled_long_value_12345" + assert redact_sensitive_text(text) == text + + # ── real key shapes still redact ──────────────────────────────────── + + def test_separator_keys_still_redacted(self): + for text in ( + "client_secret: abc123def456ghi789jkl", + "auth_token: xyz789xyz789xyz789xyz", + "my_secret: topvalue123456789012345", + "db.password=hunter2verylongpassword", + ): + result = redact_sensitive_text(text) + assert result != text, text + + def test_camelcase_keys_still_redacted(self): + for text in ( + "clientSecret: abc123def456ghi789jkl", + "secretKey: abc123def456ghi789jklmno", + "APIToken: abc123def456ghi789jklmn", + ): + result = redact_sensitive_text(text) + assert result != text, text + + def test_concatenated_compounds_still_redacted(self): + # ngrok authtoken, tailscale authkey, minio secretkey, accesstoken. + for text in ( + "authtoken: 2abcdefghij0123456789_ngrok", + "authkey=tskey-auth-abcdef123456789", + "secretkey: abc123def456ghi789jklmno", + "accesstoken: abcdefghij0123456789xyz", + ): + result = redact_sensitive_text(text) + assert result != text, text + + def test_plural_keys_still_redacted(self): + text = "secrets: hunter2hunter2hunter2hh" + result = redact_sensitive_text(text) + assert "hunter2hunter2hunter2hh" not in result + + def test_digit_boundary_still_redacted(self): + text = "oauth2_token: abcdefghij0123456789" + result = redact_sensitive_text(text) + assert "abcdefghij0123456789" not in result + + def test_all_caps_embedded_keyword_still_redacted(self): + # All-caps keys keep legacy embedded matching (MYTOKEN=…). + text = "MYTOKEN=abcdefgh1234567890123456" + result = redact_sensitive_text(text) + assert "abcdefgh1234567890123456" not in result From a751924c04daf0b7fd327613399ed0ac50305844 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:06:53 -0700 Subject: [PATCH 23/71] fix(gemini): preserve typed enum constraints as strings Port from openclaw/openclaw#104567: Gemini requires enum metadata to be strings even when the declared tool parameter type is numeric or boolean. --- agent/gemini_schema.py | 29 ++++++++++++++++----- tests/agent/test_gemini_schema.py | 43 ++++++++++++++++++++----------- 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/agent/gemini_schema.py b/agent/gemini_schema.py index b0985422fbb1..665fd79a37e4 100644 --- a/agent/gemini_schema.py +++ b/agent/gemini_schema.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from typing import Any, Dict # Gemini's ``FunctionDeclaration.parameters`` field accepts the ``Schema`` @@ -76,15 +77,31 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]: # Gemini's Schema validator requires every ``enum`` entry to be a string, # even when the parent ``type`` is ``integer`` / ``number`` / ``boolean``. - # OpenAI / OpenRouter / Anthropic accept typed enums (e.g. Discord's - # ``auto_archive_duration: {type: integer, enum: [60, 1440, 4320, 10080]}``), - # so we only drop the ``enum`` when it would collide with Gemini's rule. - # Keeping ``type: integer`` plus the human-readable description gives the - # model enough guidance; the tool handler still validates the value. + # Preserve those constraints by stringifying scalar values while keeping + # the declared type intact; Gemini uses the strings as schema metadata and + # still emits typed tool arguments at runtime. enum_val = cleaned.get("enum") type_val = cleaned.get("type") if isinstance(enum_val, list) and type_val in {"integer", "number", "boolean"}: - if any(not isinstance(item, str) for item in enum_val): + stringified = [] + for item in enum_val: + if isinstance(item, str): + value = item + elif isinstance(item, bool): + value = "true" if item else "false" + elif ( + isinstance(item, (int, float)) + and not isinstance(item, bool) + and math.isfinite(item) + ): + value = str(item) + else: + continue + if value not in stringified: + stringified.append(value) + if stringified: + cleaned["enum"] = stringified + else: cleaned.pop("enum", None) # Gemini validates ``required`` strictly against the same node's diff --git a/tests/agent/test_gemini_schema.py b/tests/agent/test_gemini_schema.py index 88bd163eb8d2..95a556901098 100644 --- a/tests/agent/test_gemini_schema.py +++ b/tests/agent/test_gemini_schema.py @@ -28,8 +28,8 @@ class TestSanitizeGeminiSchema: assert cleaned["type"] == "string" assert cleaned["enum"] == ["pending", "done", "cancelled"] - def test_drops_integer_enum_to_satisfy_gemini(self): - """Gemini rejects int-typed enums; the sanitizer must drop the enum. + def test_stringifies_integer_enum_to_satisfy_gemini(self): + """Gemini rejects numeric enum metadata unless values are strings. Regression for the Discord tool's ``auto_archive_duration``: ``{type: integer, enum: [60, 1440, 4320, 10080]}`` caused @@ -44,23 +44,23 @@ class TestSanitizeGeminiSchema: } cleaned = sanitize_gemini_schema(schema) assert cleaned["type"] == "integer" - assert "enum" not in cleaned - # description must survive so the model still sees the allowed values + assert cleaned["enum"] == ["60", "1440", "4320", "10080"] + # Description remains useful model guidance. assert cleaned["description"].startswith("Minutes") - def test_drops_number_enum(self): + def test_stringifies_number_enum(self): """Same rule applies to ``type: number``.""" schema = {"type": "number", "enum": [0.5, 1.0, 2.0]} cleaned = sanitize_gemini_schema(schema) assert cleaned["type"] == "number" - assert "enum" not in cleaned + assert cleaned["enum"] == ["0.5", "1.0", "2.0"] - def test_drops_boolean_enum(self): + def test_stringifies_boolean_enum(self): """And to ``type: boolean`` (Gemini rejects non-string entries).""" schema = {"type": "boolean", "enum": [True, False]} cleaned = sanitize_gemini_schema(schema) assert cleaned["type"] == "boolean" - assert "enum" not in cleaned + assert cleaned["enum"] == ["true", "false"] def test_keeps_string_enum_even_when_numeric_values_coexist_as_strings(self): """Stringified-numeric enums ARE valid for Gemini; don't drop them.""" @@ -68,7 +68,12 @@ class TestSanitizeGeminiSchema: cleaned = sanitize_gemini_schema(schema) assert cleaned["enum"] == ["60", "1440", "4320", "10080"] - def test_drops_nested_integer_enum_inside_properties(self): + def test_preserves_non_scalar_enum_for_non_scalar_schema(self): + schema = {"type": "object", "enum": [{"mode": "safe"}, None]} + cleaned = sanitize_gemini_schema(schema) + assert cleaned["enum"] == [{"mode": "safe"}, None] + + def test_stringifies_nested_integer_enum_inside_properties(self): """The fix must apply recursively — the Discord case is nested.""" schema = { "type": "object", @@ -86,13 +91,13 @@ class TestSanitizeGeminiSchema: } cleaned = sanitize_gemini_schema(schema) props = cleaned["properties"] - # Integer enum is dropped... + # Integer enum is retained as Gemini-compatible string metadata... assert props["auto_archive_duration"]["type"] == "integer" - assert "enum" not in props["auto_archive_duration"] + assert props["auto_archive_duration"]["enum"] == ["60", "1440", "4320", "10080"] # ...but the sibling string enum is preserved. assert props["status"]["enum"] == ["active", "archived"] - def test_drops_integer_enum_inside_array_items(self): + def test_stringifies_integer_enum_inside_array_items(self): """Array item schemas recurse through ``items``.""" schema = { "type": "array", @@ -100,7 +105,15 @@ class TestSanitizeGeminiSchema: } cleaned = sanitize_gemini_schema(schema) assert cleaned["items"]["type"] == "integer" - assert "enum" not in cleaned["items"] + assert cleaned["items"]["enum"] == ["1", "2", "3"] + + def test_filters_invalid_enum_entries_and_deduplicates(self): + schema = { + "type": "number", + "enum": [1, 1, 1.0, float("inf"), float("nan"), None, {"bad": True}], + } + cleaned = sanitize_gemini_schema(schema) + assert cleaned["enum"] == ["1", "1.0"] def test_non_dict_input_returns_empty(self): assert sanitize_gemini_schema(None) == {} @@ -218,8 +231,8 @@ class TestSanitizeGeminiToolParameters: } cleaned = sanitize_gemini_tool_parameters(params) aad = cleaned["properties"]["auto_archive_duration"] - # The field that triggered the Gemini 400 is gone. - assert "enum" not in aad + # The field that triggered the Gemini 400 is now string metadata. + assert aad["enum"] == ["60", "1440", "4320", "10080"] # Type + description survive so the model still knows what to send. assert aad["type"] == "integer" assert "1440" in aad["description"] From 214ae7b77ce642f7369d13bbce3510405f759800 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:46:27 -0700 Subject: [PATCH 24/71] feat(agent): surface a labeled reasoning excerpt at the empty-response terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the empty-response ladder is fully exhausted (thinking-prefill continuation, empty-content retries, provider fallback) and the model produced structured reasoning but never any visible text, deliver a clearly labeled excerpt of that reasoning instead of a bare '(empty)' — the reasoning frequently contains the actual answer. Delivery-only by design: raw chain-of-thought is never promoted to a normal answer earlier in the ladder (prefill continuation still gets first crack, retries and fallback still run), transcript persistence semantics are untouched (the '(empty)' sentinel scaffolding keeps its replay-safety behavior), and a truly empty exhaustion still returns the existing terminal. Idea credit: PR #48795 (@ligl0325) proposed falling back to reasoning_content on empty content; this lands the safe kernel of that idea at the one point in the ladder where it is strictly an improvement. --- agent/conversation_loop.py | 23 ++- .../test_empty_terminal_reasoning_surface.py | 161 ++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 tests/run_agent/test_empty_terminal_reasoning_surface.py diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 9eb3fdad4b04..86d26690b7fd 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -6308,7 +6308,28 @@ def run_conversation( ". No fallback providers configured.") ) - final_response = "(empty)" + # Deliver a labeled reasoning excerpt instead of a bare + # "(empty)" when the model DID think but never produced + # visible text. This is delivery-only: the persisted + # assistant message above keeps the "(empty)" sentinel + # (its replay semantics prevent empty-response loops), + # and raw chain-of-thought is never promoted to a normal + # answer earlier in the ladder — prefill continuation, + # empty-content retries, and provider fallback all run + # first. Only at this terminal, where the alternative is + # returning nothing, is showing the model's own reasoning + # (clearly labeled as such) strictly more useful. + # Idea credit: PR #48795 (@ligl0325). + if reasoning_text: + final_response = ( + "⚠️ The model produced only internal reasoning and " + "no final answer, despite retries" + + (" and fallback" if agent._fallback_chain else "") + + ". Its last reasoning, which may contain the " + "answer:\n\n" + reasoning_preview + ) + else: + final_response = "(empty)" break # Reset retry counter/signature on successful content diff --git a/tests/run_agent/test_empty_terminal_reasoning_surface.py b/tests/run_agent/test_empty_terminal_reasoning_surface.py new file mode 100644 index 000000000000..7cfd1aea02c9 --- /dev/null +++ b/tests/run_agent/test_empty_terminal_reasoning_surface.py @@ -0,0 +1,161 @@ +"""Tests for the empty-terminal reasoning surface. + +When the empty-response ladder is fully exhausted (prefill continuation, +empty-content retries, provider fallback) and the model produced structured +reasoning but no visible text, the DELIVERED final_response is a clearly +labeled reasoning excerpt instead of a bare "(empty)" — the reasoning often +contains the actual answer. Idea credit: PR #48795 (@ligl0325). + +Invariants pinned here: +- The persisted assistant message keeps the "(empty)" sentinel and the + ``_empty_terminal_sentinel`` marker (replay semantics unchanged). +- Raw reasoning is NEVER promoted earlier in the ladder — a reasoning-only + response still goes through prefill continuation first. +- A truly empty exhaustion (no reasoning either) still returns "(empty)". +""" + +from __future__ import annotations + +import sys +import types +from types import SimpleNamespace + +# Stub optional heavy imports so run_agent imports cleanly in isolation. +sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None)) +sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object)) +sys.modules.setdefault("fal_client", types.SimpleNamespace()) + + +def _build_agent(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / ".env").write_text("", encoding="utf-8") + (tmp_path / "config.yaml").write_text("{}\n", encoding="utf-8") + from run_agent import AIAgent + + agent = AIAgent( + model="test-model", + api_key="sk-dummy", + base_url="https://example.invalid/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + platform="cli", + ) + # Route through the non-streaming _interruptible_api_call path so the + # monkeypatched fake responses are what the loop consumes. + agent._disable_streaming = True + return agent + + +def _reasoning_only_response(): + return SimpleNamespace( + choices=[SimpleNamespace( + message=SimpleNamespace( + content="", + reasoning="The answer is 42 because of the calculation above.", + reasoning_content=None, + reasoning_details=None, + tool_calls=None, + ), + finish_reason="stop", + )], + usage=None, + model="test-model", + ) + + +def _truly_empty_response(): + return SimpleNamespace( + choices=[SimpleNamespace( + message=SimpleNamespace( + content="", + reasoning=None, + reasoning_content=None, + reasoning_details=None, + tool_calls=None, + ), + finish_reason="stop", + )], + usage=None, + model="test-model", + ) + + +def test_exhausted_reasoning_only_delivers_labeled_excerpt(tmp_path, monkeypatch): + """After the full ladder is exhausted on reasoning-only responses, the + delivered text is the labeled excerpt — not a bare '(empty)' — while the + transcript keeps its existing sentinel-scaffolding semantics.""" + agent = _build_agent(tmp_path, monkeypatch) + monkeypatch.setattr( + agent, "_interruptible_api_call", + lambda api_kwargs: _reasoning_only_response(), + ) + + result = agent.run_conversation("what is the answer?") + + final = result["final_response"] + assert "(empty)" != final + assert "only internal reasoning" in final + assert "The answer is 42" in final + + # Persistence semantics unchanged: the delivered excerpt is + # delivery-only. The turn finalizer strips the "(empty)" terminal + # sentinel from the transcript tail (replay safety, existing design), + # and the labeled excerpt must never be persisted as assistant content. + assert not any( + m.get("role") == "assistant" + and "only internal reasoning" in (m.get("content") or "") + for m in result["messages"] + ) + + +def test_exhausted_truly_empty_keeps_existing_behavior(tmp_path, monkeypatch): + """No reasoning anywhere → behavior unchanged from main: the '(empty)' + terminal (possibly rewritten by the downstream turn-completion explainer) + is delivered, and no reasoning excerpt appears.""" + agent = _build_agent(tmp_path, monkeypatch) + monkeypatch.setattr( + agent, "_interruptible_api_call", + lambda api_kwargs: _truly_empty_response(), + ) + + result = agent.run_conversation("hello?") + + final = result["final_response"] + # Either the raw sentinel (explainer off) or the explainer's rewrite — + # never the reasoning-excerpt frame, which requires reasoning to exist. + assert final == "(empty)" or final.startswith("⚠️ No reply:") + assert "only internal reasoning" not in final + + +def test_reasoning_never_promoted_before_ladder_exhaustion(tmp_path, monkeypatch): + """A reasoning-only response must first go through prefill continuation — + if the model then produces real text, THAT is the answer, and no labeled + reasoning excerpt appears.""" + agent = _build_agent(tmp_path, monkeypatch) + responses = [ + _reasoning_only_response(), + SimpleNamespace( + choices=[SimpleNamespace( + message=SimpleNamespace( + content="42.", + reasoning=None, + reasoning_content=None, + reasoning_details=None, + tool_calls=None, + ), + finish_reason="stop", + )], + usage=None, + model="test-model", + ), + ] + monkeypatch.setattr( + agent, "_interruptible_api_call", + lambda api_kwargs: responses.pop(0), + ) + + result = agent.run_conversation("what is the answer?") + + assert result["final_response"] == "42." + assert "only internal reasoning" not in result["final_response"] From 4854961d743d7bb9688f06a3f53b9e1410337803 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:23:54 -0700 Subject: [PATCH 25/71] test: update reasoning-only exhaustion siblings for the terminal excerpt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two sibling tests asserted the #34452 'No reply:' explainer text for reasoning-only exhaustion. That terminal now delivers the labeled reasoning excerpt (strictly more informative — it carries the model's reasoning, which may contain the answer); the explainer still covers the truly-empty case. Update the assertions to pin the new contract: excerpt present, reasoning text included, '(empty)' never delivered. --- tests/run_agent/test_run_agent.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 3dfa2aff0d4d..24370703b3e6 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -4565,10 +4565,14 @@ class TestRunConversation: mock_compress.assert_not_called() # no compression triggered assert result["completed"] is True - # #34452: the bare "(empty)" sentinel is now replaced by a - # user-visible end-of-turn explanation so the failure isn't silent. + # The bare "(empty)" sentinel is never delivered for reasoning-only + # exhaustion: the labeled reasoning excerpt (which may contain the + # answer) replaces it at the terminal. See + # test_empty_terminal_reasoning_surface.py; #34452's explainer still + # covers the truly-empty case. assert result["final_response"] != "(empty)" - assert "No reply:" in result["final_response"] + assert "only internal reasoning" in result["final_response"] + assert "reasoning only" in result["final_response"] assert result["turn_exit_reason"] == "empty_response_exhausted" assert result["api_calls"] == 6 # 1 original + 2 prefill + 3 retries @@ -4589,9 +4593,12 @@ class TestRunConversation: ): result = agent.run_conversation("answer me") assert result["completed"] is True - # #34452: explanation replaces the bare "(empty)" sentinel. + # Reasoning-only exhaustion delivers the labeled reasoning excerpt + # instead of the bare "(empty)" sentinel (see + # test_empty_terminal_reasoning_surface.py). assert result["final_response"] != "(empty)" - assert "No reply:" in result["final_response"] + assert "only internal reasoning" in result["final_response"] + assert "structured reasoning answer" in result["final_response"] assert result["api_calls"] == 6 # 1 original + 2 prefill + 3 retries def test_reasoning_only_prefill_succeeds_on_continuation(self, agent): From 9b97dea1e6e54d344899e27f108f78c3526bc863 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:35:53 -0700 Subject: [PATCH 26/71] fix(skills): parse stored GitHub credentials without scanner false positives Co-authored-by: Syed Annas <28944679+AnnasMazhar@users.noreply.github.com> Co-authored-by: Bryan Neva <13835061+bryanneva@users.noreply.github.com> --- agent/skill_utils.py | 8 +- skills/github/github-auth/SKILL.md | 4 +- skills/github/github-auth/scripts/gh-env.sh | 4 +- .../scripts/git-credential-token.py | 65 +++++++++++ skills/github/github-code-review/SKILL.md | 2 +- skills/github/github-issues/SKILL.md | 2 +- skills/github/github-pr-workflow/SKILL.md | 2 +- skills/github/github-repo-management/SKILL.md | 2 +- tests/agent/test_skill_utils.py | 16 +++ tests/skills/test_github_credential_token.py | 101 ++++++++++++++++++ tests/tools/test_skills_hub.py | 18 ++++ tests/tools/test_skills_sync.py | 10 ++ tools/skills_hub.py | 8 +- tools/skills_sync.py | 12 ++- .../bundled/github/github-github-auth.md | 4 +- .../github/github-github-code-review.md | 2 +- .../bundled/github/github-github-issues.md | 2 +- .../github/github-github-pr-workflow.md | 2 +- .../github/github-github-repo-management.md | 2 +- .../bundled/github/github-github-auth.md | 4 +- .../github/github-github-code-review.md | 2 +- .../bundled/github/github-github-issues.md | 2 +- .../github/github-github-pr-workflow.md | 2 +- .../github/github-github-repo-management.md | 2 +- 24 files changed, 251 insertions(+), 27 deletions(-) create mode 100644 skills/github/github-auth/scripts/git-credential-token.py create mode 100644 tests/skills/test_github_credential_token.py diff --git a/agent/skill_utils.py b/agent/skill_utils.py index df0f933317fc..eea78d6a07c0 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -50,7 +50,7 @@ EXCLUDED_SKILL_DIRS = frozenset( SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts")) -def is_excluded_skill_path(path) -> bool: +def is_excluded_skill_path(path, *, root: Optional[Path] = None) -> bool: """True if *path* should be skipped by active skill scanners. Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to @@ -66,11 +66,11 @@ def is_excluded_skill_path(path) -> bool: from pathlib import PurePath parts = PurePath(str(path)).parts return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path( - path + path, root=root ) -def is_skill_support_path(path) -> bool: +def is_skill_support_path(path, *, root: Optional[Path] = None) -> bool: """True if *path* is under a support dir of an actual skill root. ``references/``, ``templates/``, ``assets/``, and ``scripts/`` are @@ -92,6 +92,8 @@ def is_skill_support_path(path) -> bool: if part not in SKILL_SUPPORT_DIRS or idx == 0: continue skill_root = Path(*parts[:idx]) + if root is not None and not path_obj.is_absolute(): + skill_root = root / skill_root if (skill_root / "SKILL.md").exists(): return True return False diff --git a/skills/github/github-auth/SKILL.md b/skills/github/github-auth/SKILL.md index 95606d36709b..b5e4057426a2 100644 --- a/skills/github/github-auth/SKILL.md +++ b/skills/github/github-auth/SKILL.md @@ -207,7 +207,7 @@ If git credentials are already configured (via credential.helper store), the tok ```bash # Read from git credential store -grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|' +uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py" ``` ### Helper: Detect Auth Method @@ -224,7 +224,7 @@ elif _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && export GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') echo "AUTH_METHOD=curl" elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - export GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + export GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") echo "AUTH_METHOD=curl" else echo "AUTH_METHOD=none" diff --git a/skills/github/github-auth/scripts/gh-env.sh b/skills/github/github-auth/scripts/gh-env.sh index 47b3ff98c5c8..5017aa732f5a 100755 --- a/skills/github/github-auth/scripts/gh-env.sh +++ b/skills/github/github-auth/scripts/gh-env.sh @@ -28,8 +28,8 @@ elif _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && if [ -n "$GITHUB_TOKEN" ]; then GH_AUTH_METHOD="curl" fi -elif [ -f "$HOME/.git-credentials" ] && grep -q "github.com" "$HOME/.git-credentials" 2>/dev/null; then - GITHUB_TOKEN=$(grep "github.com" "$HOME/.git-credentials" | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') +elif [ -f "$HOME/.git-credentials" ]; then + GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") if [ -n "$GITHUB_TOKEN" ]; then GH_AUTH_METHOD="curl" fi diff --git a/skills/github/github-auth/scripts/git-credential-token.py b/skills/github/github-auth/scripts/git-credential-token.py new file mode 100644 index 000000000000..7955aed6fac9 --- /dev/null +++ b/skills/github/github-auth/scripts/git-credential-token.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Print the first unambiguous GitHub token in a git credential-store file.""" + +from pathlib import Path +import re +import sys +from urllib.parse import unquote, urlsplit + + +_TOKEN_PREFIXES = ("ghp_", "github_pat_", "gho_", "ghu_", "ghs_", "ghr_") +_INVALID_ESCAPE = re.compile(r"%(?![0-9A-Fa-f]{2})") + + +def _decode(value: str | None) -> str: + if value is None or _INVALID_ESCAPE.search(value): + return "" + decoded = unquote(value) + if not decoded or any(ord(char) <= 0x1F or 0x7F <= ord(char) <= 0x9F for char in decoded): + return "" + return decoded + + +def _token_from_url(line: str) -> str: + if "\r" in line or "\n" in line: + return "" + try: + credential = urlsplit(line) + port = credential.port + except ValueError: + return "" + if credential.scheme != "https" or credential.hostname != "github.com" or port not in (None, 443): + return "" + + username = _decode(credential.username) + password = _decode(credential.password) + if not username: + return "" + if password and password != "x-oauth-basic": + return password + if password == "x-oauth-basic": + return username + return username if username.startswith(_TOKEN_PREFIXES) else "" + + +def main() -> int: + path = Path(sys.argv[1]).expanduser() if len(sys.argv) > 1 else Path.home() / ".git-credentials" + try: + lines = path.read_bytes().split(b"\n") + except OSError: + return 1 + + for raw_line in lines: + try: + line = raw_line.decode("utf-8") + except UnicodeDecodeError: + continue + token = _token_from_url(line) + if token: + print(token) + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/github/github-code-review/SKILL.md b/skills/github/github-code-review/SKILL.md index b58309235126..8f59405fb2f8 100644 --- a/skills/github/github-code-review/SKILL.md +++ b/skills/github/github-code-review/SKILL.md @@ -31,7 +31,7 @@ else if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") fi fi fi diff --git a/skills/github/github-issues/SKILL.md b/skills/github/github-issues/SKILL.md index bded118a10a9..415e10d449a5 100644 --- a/skills/github/github-issues/SKILL.md +++ b/skills/github/github-issues/SKILL.md @@ -31,7 +31,7 @@ else if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") fi fi fi diff --git a/skills/github/github-pr-workflow/SKILL.md b/skills/github/github-pr-workflow/SKILL.md index 69eb21183d3d..f076d08b623d 100644 --- a/skills/github/github-pr-workflow/SKILL.md +++ b/skills/github/github-pr-workflow/SKILL.md @@ -33,7 +33,7 @@ else if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") fi fi fi diff --git a/skills/github/github-repo-management/SKILL.md b/skills/github/github-repo-management/SKILL.md index 1026ce36e494..54565d7a10b5 100644 --- a/skills/github/github-repo-management/SKILL.md +++ b/skills/github/github-repo-management/SKILL.md @@ -30,7 +30,7 @@ else if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") fi fi fi diff --git a/tests/agent/test_skill_utils.py b/tests/agent/test_skill_utils.py index 471acd404195..4a860a2077da 100644 --- a/tests/agent/test_skill_utils.py +++ b/tests/agent/test_skill_utils.py @@ -241,6 +241,22 @@ def test_iter_skill_index_files_keeps_support_named_categories(tmp_path): assert is_excluded_skill_path(scripts_skill / "SKILL.md") is False +def test_skill_support_path_uses_explicit_discovery_root_not_cwd(tmp_path, monkeypatch): + discovery_root = tmp_path / "site-packages" / "skills" + umbrella = discovery_root / "category" / "umbrella" + nested = umbrella / "references" / "archived" / "SKILL.md" + nested.parent.mkdir(parents=True) + (umbrella / "SKILL.md").write_text("---\nname: umbrella\n---\n", encoding="utf-8") + nested.write_text("---\nname: archived\n---\n", encoding="utf-8") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + + relative = nested.relative_to(discovery_root) + assert is_skill_support_path(relative, root=discovery_root) is True + assert is_excluded_skill_path(relative, root=discovery_root) is True + + # ── skill_matches_platform on Termux ────────────────────────────────────── diff --git a/tests/skills/test_github_credential_token.py b/tests/skills/test_github_credential_token.py new file mode 100644 index 000000000000..433111fcf709 --- /dev/null +++ b/tests/skills/test_github_credential_token.py @@ -0,0 +1,101 @@ +"""Regression tests for Tirith-safe GitHub credential extraction (#22722).""" + +from pathlib import Path +import subprocess +import sys + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +HELPER = REPO_ROOT / "skills/github/github-auth/scripts/git-credential-token.py" +LEGACY_SED = r"sed 's|https://[^:]*:\([^@]*\)@.*|\1|'" +SHIPPED_TREES = ( + REPO_ROOT / "skills/github", + REPO_ROOT / "website/docs/user-guide/skills/bundled/github", + REPO_ROOT + / "website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github", +) + + +def _extract(path: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(HELPER), str(path)], + capture_output=True, + text=True, + check=False, + ) + + +def _credential_file(tmp_path: Path, value: str) -> Path: + credentials = tmp_path / "credentials" + credentials.write_text(value, encoding="utf-8", newline="") + return credentials + + +@pytest.mark.parametrize( + ("credential", "token"), + [ + ("https://octocat:password-form-token@github.com\n", "password-form-token"), + ("https://oauth-token:x-oauth-basic@github.com\n", "oauth-token"), + ("https://ghp_token_only@github.com\n", "ghp_token_only"), + ("https://github_pat_token_only@github.com\n", "github_pat_token_only"), + ], +) +def test_extracts_supported_git_credential_url_forms(tmp_path, credential, token): + result = _extract(_credential_file(tmp_path, credential)) + + assert result.returncode == 0 + assert result.stdout == f"{token}\n" + assert result.stderr == "" + + +def test_extracts_password_from_exact_github_https_credential(tmp_path): + credentials = _credential_file( + tmp_path, + "https://ignored:wrong@example.com\n" + "https://octocat:secret%2Ftoken@github.com\n", + ) + + result = _extract(credentials) + + assert result.returncode == 0 + assert result.stdout == "secret/token\n" + assert result.stderr == "" + + +@pytest.mark.parametrize( + "credential", + [ + "https://octocat:stolen@github.com.attacker.example\n", + "https://octocat@github.com\n", + "https://%6fctocat@github.com\n", + "https://octocat:token@github.com%2eattacker.example\n", + "https://octocat:token%0D%0AX-Injected%3Ayes@github.com\n", + "https://ghp_token%0Ainjected@github.com\n", + "https://octocat:token%00suffix@github.com\n", + "https://octocat:token%09suffix@github.com\n", + "https://octocat:token%C2%85suffix@github.com\n", + "https://ghp_token%1Fsuffix@github.com\n", + "https://ghp_token%C2%9Fsuffix@github.com\n", + "https://octocat:bad%ZZtoken@github.com\n", + "https://octocat:token@github.com:bogus\n", + "http://octocat:token@github.com\n", + ], +) +def test_rejects_ambiguous_lookalike_or_malformed_credentials(tmp_path, credential): + result = _extract(_credential_file(tmp_path, credential)) + + assert result.returncode == 1 + assert result.stdout == "" + assert result.stderr == "" + + +def test_bundled_github_skills_and_docs_do_not_ship_legacy_sed_url_regex(): + offenders = [] + for tree in SHIPPED_TREES: + for path in tree.rglob("*"): + if path.suffix in {".md", ".sh", ".py"} and LEGACY_SED in path.read_text(encoding="utf-8"): + offenders.append(str(path.relative_to(REPO_ROOT))) + + assert offenders == [] diff --git a/tests/tools/test_skills_hub.py b/tests/tools/test_skills_hub.py index c6b13f85be2d..c8ae082dff95 100644 --- a/tests/tools/test_skills_hub.py +++ b/tests/tools/test_skills_hub.py @@ -1848,6 +1848,24 @@ class TestOptionalSkillSourceMetadata: assert meta.repo == "NousResearch/hermes-agent" assert meta.path == "optional-skills/finance/3-statement-model" + def test_scan_all_accepts_install_prefix_but_rejects_nested_support_skills(self, tmp_path): + optional_root = tmp_path / "venv" / "lib" / "site-packages" / "optional-skills" + real = optional_root / "research" / "real-skill" + nested = real / "references" / "archived-skill" + nested.mkdir(parents=True) + (real / "SKILL.md").write_text( + "---\nname: real-skill\ndescription: real\n---\n", encoding="utf-8" + ) + (nested / "SKILL.md").write_text( + "---\nname: archived-skill\ndescription: nested\n---\n", encoding="utf-8" + ) + + src = OptionalSkillSource() + src._optional_dir = optional_root + + assert [meta.name for meta in src._scan_all()] == ["real-skill"] + assert src._find_skill_dir("archived-skill") is None + class TestOptionalSkillSourceBinaryAssets: def test_fetch_preserves_binary_assets(self, tmp_path): diff --git a/tests/tools/test_skills_sync.py b/tests/tools/test_skills_sync.py index 47f957fda40c..062284a46049 100644 --- a/tests/tools/test_skills_sync.py +++ b/tests/tools/test_skills_sync.py @@ -131,6 +131,16 @@ class TestDiscoverBundledSkills: skills = _discover_bundled_skills(tmp_path) assert len(skills) == 0 + @pytest.mark.parametrize("support_dir", ["references", "scripts", "templates", "assets"]) + def test_ignores_nested_skill_packages_in_support_dirs(self, tmp_path, support_dir): + real = tmp_path / "category" / "umbrella" + nested = real / support_dir / "archived-skill" + nested.mkdir(parents=True) + (real / "SKILL.md").write_text("---\nname: umbrella\n---\n") + (nested / "SKILL.md").write_text("---\nname: archived-skill\n---\n") + + assert [name for name, _ in _discover_bundled_skills(tmp_path)] == ["umbrella"] + def test_nonexistent_dir_returns_empty(self, tmp_path): skills = _discover_bundled_skills(tmp_path / "nonexistent") assert skills == [] diff --git a/tools/skills_hub.py b/tools/skills_hub.py index ab186b403d0f..7753b56c89df 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -3277,7 +3277,9 @@ class OptionalSkillSource(SkillSource): if not self._optional_dir.is_dir(): return None for skill_md in self._optional_dir.rglob("SKILL.md"): - if is_excluded_skill_path(skill_md): + if is_excluded_skill_path( + skill_md.relative_to(self._optional_dir), root=self._optional_dir + ): continue if skill_md.parent.name == name: return skill_md.parent @@ -3290,7 +3292,9 @@ class OptionalSkillSource(SkillSource): results: List[SkillMeta] = [] for skill_md in sorted(self._optional_dir.rglob("SKILL.md")): - if is_excluded_skill_path(skill_md): + if is_excluded_skill_path( + skill_md.relative_to(self._optional_dir), root=self._optional_dir + ): continue parent = skill_md.parent diff --git a/tools/skills_sync.py b/tools/skills_sync.py index de0545e308cb..288aa2dac51e 100644 --- a/tools/skills_sync.py +++ b/tools/skills_sync.py @@ -226,7 +226,13 @@ def _discover_bundled_skills(bundled_dir: Path) -> List[Tuple[str, Path]]: return skills for skill_md in bundled_dir.rglob("SKILL.md"): - if is_excluded_skill_path(skill_md): + # Exclusions apply inside the bundled tree. The install prefix itself + # may legitimately contain names such as ``venv`` or ``site-packages``; + # treating those parent components as skill content makes every wheel + # install discover zero bundled skills. + if is_excluded_skill_path( + skill_md.relative_to(bundled_dir), root=bundled_dir + ): continue skill_dir = skill_md.parent skill_name = _read_skill_name(skill_md, skill_dir.name) @@ -302,7 +308,9 @@ def _optional_skill_index() -> Dict[str, Tuple[str, str, Path]]: if not optional_dir.exists(): return index for skill_md in sorted(optional_dir.rglob("SKILL.md")): - if is_excluded_skill_path(skill_md): + if is_excluded_skill_path( + skill_md.relative_to(optional_dir), root=optional_dir + ): continue src = skill_md.parent try: diff --git a/website/docs/user-guide/skills/bundled/github/github-github-auth.md b/website/docs/user-guide/skills/bundled/github/github-github-auth.md index 35e631fb2376..ba58277c4e96 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-auth.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-auth.md @@ -225,7 +225,7 @@ If git credentials are already configured (via credential.helper store), the tok ```bash # Read from git credential store -grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|' +uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py" ``` ### Helper: Detect Auth Method @@ -242,7 +242,7 @@ elif _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && export GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') echo "AUTH_METHOD=curl" elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - export GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + export GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") echo "AUTH_METHOD=curl" else echo "AUTH_METHOD=none" diff --git a/website/docs/user-guide/skills/bundled/github/github-github-code-review.md b/website/docs/user-guide/skills/bundled/github/github-github-code-review.md index a7adc59e1197..504188699998 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-code-review.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-code-review.md @@ -49,7 +49,7 @@ else if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") fi fi fi diff --git a/website/docs/user-guide/skills/bundled/github/github-github-issues.md b/website/docs/user-guide/skills/bundled/github/github-github-issues.md index fa3dc52c7e21..e25ceb4cb24e 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-issues.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-issues.md @@ -49,7 +49,7 @@ else if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") fi fi fi diff --git a/website/docs/user-guide/skills/bundled/github/github-github-pr-workflow.md b/website/docs/user-guide/skills/bundled/github/github-github-pr-workflow.md index a0221be3d735..0536a0dd6a57 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-pr-workflow.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-pr-workflow.md @@ -51,7 +51,7 @@ else if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") fi fi fi diff --git a/website/docs/user-guide/skills/bundled/github/github-github-repo-management.md b/website/docs/user-guide/skills/bundled/github/github-github-repo-management.md index b87a7abdf375..f540d02eb264 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-repo-management.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-repo-management.md @@ -48,7 +48,7 @@ else if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") fi fi fi diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-auth.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-auth.md index 623fd03b9bed..ca85ae3f3bf9 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-auth.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-auth.md @@ -225,7 +225,7 @@ curl -s -H "Authorization: token $GITHUB_TOKEN" \ ```bash # Read from git credential store -grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|' +uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py" ``` ### 辅助函数:检测认证方式 @@ -242,7 +242,7 @@ elif [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then export GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') echo "AUTH_METHOD=curl" elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - export GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + export GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") echo "AUTH_METHOD=curl" else echo "AUTH_METHOD=none" diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-code-review.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-code-review.md index d9c20243da5b..479fc78270aa 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-code-review.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-code-review.md @@ -49,7 +49,7 @@ else if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") fi fi fi diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-issues.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-issues.md index 6b601aaf39df..cd7507b28450 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-issues.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-issues.md @@ -49,7 +49,7 @@ else if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") fi fi fi diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-pr-workflow.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-pr-workflow.md index b914f0ac4d38..9a1bb42e1570 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-pr-workflow.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-pr-workflow.md @@ -51,7 +51,7 @@ else if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") fi fi fi diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-repo-management.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-repo-management.md index 62d2b9ad775d..06f4f3289974 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-repo-management.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/github/github-github-repo-management.md @@ -48,7 +48,7 @@ else if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then - GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') + GITHUB_TOKEN=$(uv run python3 "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/git-credential-token.py") fi fi fi From 8fbe2e388fe2dddb31cc64dee39f3e841c2be968 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:30:17 -0700 Subject: [PATCH 27/71] feat(tool_search): probe-validate blind tool_call args against the deferred schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from nearai/ironclaw#5149 (the describe-first live-hardening fix in their progressive tool disclosure work): when a model invokes a deferred tool through the tool_call bridge without the schema-required arguments, return the tool's parameter schema instead of dispatching blind. Pre-fix, a blind call produced an opaque downstream failure ("[TOOL_ERROR] Tool execution failed: KeyError: 'document_id'") that teaches the model nothing about what the tool expects — IronClaw observed cheap models looping ~30 identical invalid calls until the iteration budget died. Post-fix, the model repairs the call in one round-trip. - tools/tool_search.py: new validate_deferred_call_args() — key-absence check of schema 'required' fields only; no type checking (coerce_tool_args already repairs types downstream); fails open on any validator error so it can never block a legitimate dispatch. - model_tools.py: probe after the scope gate in the bridge dispatch. - agent/tool_executor.py: probe in both unwrap sites (concurrent + sequential) before the underlying tool replaces the bridge; sequential path flattens the payload to match its {"error": str} wrapping. - tests: TestDeferredCallSchemaProbe — blind call returns schema (not KeyError), valid/optional calls dispatch, unvalidatable tools fail open, out-of-scope rejection unchanged. --- agent/tool_executor.py | 32 ++++++++-- model_tools.py | 6 ++ tests/tools/test_tool_search.py | 109 ++++++++++++++++++++++++++++++++ tools/tool_search.py | 56 ++++++++++++++++ 4 files changed, 199 insertions(+), 4 deletions(-) diff --git a/agent/tool_executor.py b/agent/tool_executor.py index c6751ac6c941..74092b90ee91 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -431,8 +431,15 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe _underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args) if not _err and _underlying: if _underlying in _tool_search_scoped_names(agent): - function_name = _underlying - function_args = _underlying_args + # Probe-validate before unwrapping (ironclaw#5149): + # missing required args return the parameter schema + # instead of dispatching into an opaque failure. + _probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args) + if _probe_err is not None: + _ts_scope_block = _probe_err + else: + function_name = _underlying + function_args = _underlying_args else: _ts_scope_block = json.dumps({ "error": ( @@ -1113,8 +1120,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe _underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args) if not _err and _underlying: if _underlying in _tool_search_scoped_names(agent): - function_name = _underlying - function_args = _underlying_args + # Probe-validate before unwrapping (ironclaw#5149): + # missing required args return the parameter schema + # instead of dispatching into an opaque failure. + _probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args) + if _probe_err is not None: + # This path wraps _block_msg in {"error": ...} — + # flatten the probe payload to one plain string. + try: + _probe = json.loads(_probe_err) + _ts_scope_block = ( + f"{_probe.get('error', '')} Parameters schema: " + f"{json.dumps(_probe.get('parameters', {}), ensure_ascii=False)}. " + f"{_probe.get('hint', '')}" + ).strip() + except Exception: + _ts_scope_block = _probe_err + else: + function_name = _underlying + function_args = _underlying_args else: _ts_scope_block = ( f"'{_underlying}' is not available in this session. " diff --git a/model_tools.py b/model_tools.py index db36c1bfd8d6..7b5811e8fbfe 100644 --- a/model_tools.py +++ b/model_tools.py @@ -1185,6 +1185,12 @@ def handle_function_call( "Use tool_search to find tools you can call." ), }, ensure_ascii=False) + # Probe-validate against the deferred tool's schema (ironclaw#5149): + # a blind call missing required arguments returns the parameter + # schema instead of dispatching into an opaque downstream failure. + _probe_err = _ts_mod.validate_deferred_call_args(underlying_name, underlying_args) + if _probe_err is not None: + return _probe_err # Recurse with the underlying tool. All hooks fire against the # real tool name. The bridge is invisible to hooks by design. return handle_function_call( diff --git a/tests/tools/test_tool_search.py b/tests/tools/test_tool_search.py index 117047ecfeb7..182871634d21 100644 --- a/tests/tools/test_tool_search.py +++ b/tests/tools/test_tool_search.py @@ -739,3 +739,112 @@ class TestCatalogListing: assert result.activated search = next(t for t in result.tool_defs if t["function"]["name"] == "tool_search") assert "mcp_x_0" not in search["function"]["description"] + + +class TestDeferredCallSchemaProbe: + """Blind tool_call invocations missing required arguments must return + the tool's parameter schema instead of dispatching into an opaque + downstream failure (port of nearai/ironclaw#5149's describe-first fix). + + A deferred tool's schema is invisible until tool_describe is called, so + models routinely invoke deferred tools by name alone. Pre-fix, that + produced ``KeyError: 'document_id'``-style errors that teach the model + nothing; post-fix, the probe returns the schema so the model repairs + the call in one round-trip. Valid calls dispatch untouched. + """ + + @staticmethod + def _register(name, toolset, required=("document_id",)): + from tools.registry import registry + + def _handler(args, task_id=None, **kw): + # Simulates a tool that crashes opaquely on a missing required arg. + return json.dumps({"ok": True, "doc": args["document_id"]}) + + params = { + "type": "object", + "properties": { + "document_id": {"type": "string", "description": "Doc id"}, + "format": {"type": "string"}, + }, + "required": list(required), + } + registry.register( + name=name, + handler=_handler, + schema={"type": "function", + "function": {"name": name, "description": f"desc {name}", + "parameters": params}}, + toolset=toolset, + ) + + def test_validator_returns_schema_for_missing_required(self): + from tools.tool_search import validate_deferred_call_args + + self._register("mcp_probe_docs_get", "mcp-probe") + err = validate_deferred_call_args("mcp_probe_docs_get", {}) + assert err is not None + parsed = json.loads(err) + assert "document_id" in parsed["error"] + assert "NOT invoked" in parsed["error"] + assert parsed["parameters"]["required"] == ["document_id"] + assert "document_id" in parsed["parameters"]["properties"] + + def test_validator_passes_valid_and_optional_only_calls(self): + from tools.tool_search import validate_deferred_call_args + + self._register("mcp_probe_docs_get2", "mcp-probe") + # All required present → dispatch. + assert validate_deferred_call_args( + "mcp_probe_docs_get2", {"document_id": "abc"}) is None + # Extra optional args don't matter. + assert validate_deferred_call_args( + "mcp_probe_docs_get2", {"document_id": "abc", "format": "md"}) is None + + def test_validator_never_blocks_unvalidatable_tools(self): + from tools.tool_search import validate_deferred_call_args + + # Unknown tool → no schema → dispatch (downstream scope gate handles it). + assert validate_deferred_call_args("mcp_no_such_tool_xyz", {}) is None + + def test_validator_no_required_list_dispatches(self): + from tools.tool_search import validate_deferred_call_args + from tools.registry import registry + + registry.register( + name="mcp_probe_norequired", + handler=lambda args, task_id=None, **kw: json.dumps({"ok": True}), + schema={"type": "function", + "function": {"name": "mcp_probe_norequired", + "description": "d", + "parameters": {"type": "object", "properties": {}}}}, + toolset="mcp-probe", + ) + assert validate_deferred_call_args("mcp_probe_norequired", {}) is None + + def test_blind_tool_call_returns_schema_not_keyerror(self): + import model_tools + + self._register("mcp_probe_blind_op", "mcp-probe-blind") + result = json.loads(model_tools.handle_function_call( + function_name="tool_call", + function_args={"name": "mcp_probe_blind_op", "arguments": {}}, + enabled_toolsets=["mcp-probe-blind"], + )) + assert "error" in result + assert "KeyError" not in result["error"] + assert "missing required argument" in result["error"] + assert result["parameters"]["required"] == ["document_id"] + + def test_valid_tool_call_still_dispatches(self): + import model_tools + + self._register("mcp_probe_valid_op", "mcp-probe-valid") + result = json.loads(model_tools.handle_function_call( + function_name="tool_call", + function_args={"name": "mcp_probe_valid_op", + "arguments": {"document_id": "abc"}}, + enabled_toolsets=["mcp-probe-valid"], + )) + assert result.get("ok") is True + assert result.get("doc") == "abc" diff --git a/tools/tool_search.py b/tools/tool_search.py index ccfddcc46e45..df1e29b3c03c 100644 --- a/tools/tool_search.py +++ b/tools/tool_search.py @@ -934,6 +934,61 @@ def scoped_deferrable_names(tool_defs: List[Dict[str, Any]]) -> frozenset[str]: return frozenset(names) +def validate_deferred_call_args(name: str, args: Dict[str, Any]) -> Optional[str]: + """Probe-validate ``tool_call`` arguments against the deferred tool's schema. + + A deferred tool's parameter schema is invisible to the model until it + calls ``tool_describe`` — so models routinely invoke deferred tools + "blind" by name alone, omitting required arguments. Dispatching such a + call produces an opaque downstream failure (``KeyError: 'document_id'``) + that tells the model nothing about what the tool expects, and cheap + models loop on it until the iteration budget dies. + + Port of the describe-first probe-validation fix from nearai/ironclaw#5149: + when required arguments are missing, return the tool's parameter schema + instead of dispatching blind — the model repairs the call in one + round-trip. Valid calls (and any call we can't confidently validate) + dispatch untouched, so this can never block a legitimate invocation. + + Only *key absence* of schema-``required`` fields counts as invalid. + No type checking, no null rejection — nullable/typed edge cases are the + tool's own business, and ``coerce_tool_args`` already handles type repair + downstream. Returns a JSON error string when invalid, ``None`` when the + call should dispatch. + """ + try: + from tools.registry import registry as _registry + schema = _registry.get_schema(name) + if not isinstance(schema, dict): + return None + fn = schema.get("function") if schema.get("type") == "function" else schema + if not isinstance(fn, dict): + return None + params = fn.get("parameters") + if not isinstance(params, dict): + return None + required = params.get("required") + if not isinstance(required, list) or not required: + return None + missing = [r for r in required if isinstance(r, str) and r not in args] + if not missing: + return None + return json.dumps({ + "error": ( + f"tool_call to '{name}' is missing required argument(s): " + f"{', '.join(missing)}. The tool was NOT invoked." + ), + "parameters": params, + "hint": ( + "Retry tool_call with 'arguments' matching the parameters " + "schema above." + ), + }, ensure_ascii=False) + except Exception: # pragma: no cover — never block dispatch on validator bugs + logger.debug("validate_deferred_call_args failed for %s", name, exc_info=True) + return None + + def resolve_underlying_call(args: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any], Optional[str]]: """Parse a ``tool_call`` invocation into (underlying_name, args, error_msg). @@ -992,4 +1047,5 @@ __all__ = [ "dispatch_tool_describe", "resolve_underlying_call", "scoped_deferrable_names", + "validate_deferred_call_args", ] From f9cd57791577360430aa38c97600ec93a2a56f48 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:14:01 -0700 Subject: [PATCH 28/71] feat(approvals): add cross-surface mode command Co-authored-by: luxiaolu4827 <227715866+luxiaolu4827@users.noreply.github.com> --- .../hooks/use-prompt-actions/index.test.tsx | 41 ++++ .../src/lib/desktop-slash-commands.test.ts | 3 + .../desktop/src/lib/desktop-slash-commands.ts | 6 + cli.py | 2 + gateway/run.py | 3 + gateway/slash_commands.py | 17 ++ hermes_cli/approval_mode.py | 87 +++++++++ hermes_cli/cli_commands_mixin.py | 10 + hermes_cli/commands.py | 12 +- tests/gateway/test_approvals_command.py | 91 +++++++++ tests/hermes_cli/test_approvals_command.py | 180 ++++++++++++++++++ tests/test_tui_gateway_server.py | 2 + 12 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 hermes_cli/approval_mode.py create mode 100644 tests/gateway/test_approvals_command.py create mode 100644 tests/hermes_cli/test_approvals_command.py diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 28fd021fcf10..068ac9f23ca5 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -929,6 +929,47 @@ describe('usePromptActions slash.exec dispatch payloads', () => { vi.restoreAllMocks() }) + it('executes /approvals against the focused profile session and persists its mode', async () => { + const focusedProfile = 'work' + const focusedSessionId = 'work-runtime-session' + const persistedModes = new Map<string, string>() + const sessionProfiles = new Map([[focusedSessionId, focusedProfile]]) + const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { + if (method === 'slash.exec') { + const sessionId = String(params?.session_id ?? '') + const profile = sessionProfiles.get(sessionId) + const command = String(params?.command ?? '') + + if (profile && command === 'approvals off') persistedModes.set(profile, 'off') + + return { output: 'Approval mode: off (persistent profile setting).' } as never + } + + return {} as never + }) + let handle: HarnessHandle | null = null + + render( + <Harness + activeSessionId={focusedSessionId} + activeSessionIdRef={{ current: focusedSessionId }} + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={focusedSessionId} + /> + ) + + await handle!.submitText('/approvals off') + + expect(requestGateway).toHaveBeenCalledWith('slash.exec', { + command: 'approvals off', + session_id: focusedSessionId + }) + expect(persistedModes.get(focusedProfile)).toBe('off') + expect(persistedModes.has('default')).toBe(false) + }) + it('submits /goal send directives returned directly by slash.exec instead of rendering no output', async () => { const calls: { method: string; params?: Record<string, unknown> }[] = [] const states: Record<string, unknown>[] = [] diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index efb16453479a..d8aa1897652d 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -21,6 +21,9 @@ describe('desktop slash command curation', () => { expect(isDesktopSlashSuggestion('/version')).toBe(true) expect(isDesktopSlashSuggestion('/yolo')).toBe(true) expect(isDesktopSlashCommand('/yolo')).toBe(true) + expect(isDesktopSlashSuggestion('/approvals')).toBe(true) + expect(isDesktopSlashCommand('/approvals')).toBe(true) + expect(resolveDesktopCommand('/approvals')?.surface).toEqual({ kind: 'exec' }) }) it('surfaces skill and quick commands (extensions) in suggestions and lets them run', () => { diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index 399479242f62..5fccef48c719 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -190,6 +190,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ // them, /steer falls back to a next-turn prompt, and /usage is a formatted // live report. Keep them on slash.exec until their RPC contracts are fully // equivalent. + { + name: '/approvals', + description: 'Show or set approval mode [manual|smart|off]', + surface: exec(), + args: true + }, { name: '/agents', description: 'Show active desktop sessions and running tasks', diff --git a/cli.py b/cli.py index 34bb7bb6b35b..d7042309a000 100644 --- a/cli.py +++ b/cli.py @@ -9701,6 +9701,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._handle_footer_command(cmd_original) elif canonical == "yolo": self._toggle_yolo() + elif canonical == "approvals": + self._handle_approvals_command(cmd_original) elif canonical == "reasoning": self._handle_reasoning_command(cmd_original) elif canonical == "fast": diff --git a/gateway/run.py b/gateway/run.py index 03184233caf3..cb5aa02490ea 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11977,6 +11977,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "yolo": return await self._handle_yolo_command(event) + if canonical == "approvals": + return await self._handle_approvals_command(event) + if canonical == "model": return await self._handle_model_command(event) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 780026bfa2fa..cf1d44929cbc 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3649,6 +3649,23 @@ class GatewaySlashCommandsMixin: return _apply_fast_selection(args, persist=persist_global) + async def _handle_approvals_command(self, event: MessageEvent) -> str: + """Show or persist the profile-wide dangerous-command approval mode.""" + from gateway.slash_access import policy_for_source + from hermes_cli.approval_mode import run_approval_mode_command + + requested = event.get_command_args().strip() or None + # This mutates profile-wide security policy. The central slash gate can + # allow selected commands to non-admin users, so enforce admin again at + # this side-effect boundary. Unconfigured policies remain unrestricted. + policy = policy_for_source(self.config, event.source) + if requested and not policy.is_admin(event.source.user_id): + return "Only gateway admins can change the persistent approval mode." + result = run_approval_mode_command(requested) + # Approval checks load config dynamically; do not evict the cached agent + # or alter its system prompt/tool schema (prompt-cache prefix is sacred). + return result.message + async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: """Handle /yolo — toggle dangerous command approval bypass for this session only.""" from tools.approval import ( diff --git a/hermes_cli/approval_mode.py b/hermes_cli/approval_mode.py new file mode 100644 index 000000000000..5e29552a0fdf --- /dev/null +++ b/hermes_cli/approval_mode.py @@ -0,0 +1,87 @@ +"""Shared persistent approval-mode command logic. + +Approval mode is profile-scoped configuration, not conversation state. Changing +it affects subsequent terminal guard checks immediately because approval.py +loads config on each check; it must not rebuild a live agent or mutate its +system prompt/tool schema, preserving the prompt-cache prefix. +""" + +from __future__ import annotations + +from contextlib import redirect_stderr, redirect_stdout +from dataclasses import dataclass +from io import StringIO +from typing import Optional + +VALID_APPROVAL_MODES = ("manual", "smart", "off") + + +@dataclass(frozen=True) +class ApprovalModeResult: + ok: bool + mode: str + changed: bool + message: str + + +def _effective_mode() -> str: + """Return the exact mode enforced by the terminal approval guard.""" + from tools.approval import _get_approval_mode + + return _get_approval_mode() + + +def run_approval_mode_command(requested_mode: Optional[str]) -> ApprovalModeResult: + """Inspect or persist ``approvals.mode`` through canonical config APIs.""" + current = _effective_mode() + requested = (requested_mode or "").strip().lower() + + if not requested: + return ApprovalModeResult( + True, + current, + False, + f"Approval mode: {current} (persistent profile setting).", + ) + if requested not in VALID_APPROVAL_MODES: + return ApprovalModeResult( + False, + current, + False, + "Usage: /approvals [manual|smart|off]", + ) + + # set_config_value is the canonical managed-scope/write-safety chokepoint. + # It reports managed policy through stderr + SystemExit, so capture that for + # slash-command output instead of terminating the interactive worker. + from hermes_cli.config import set_config_value + + output = StringIO() + try: + with redirect_stdout(output), redirect_stderr(output): + set_config_value("approvals.mode", requested) + except SystemExit: + detail = output.getvalue().strip() or "Approval mode is managed and cannot be changed." + return ApprovalModeResult(False, current, False, detail) + except Exception as exc: + return ApprovalModeResult( + False, + current, + False, + f"Failed to save approval mode: {exc}", + ) + + effective = _effective_mode() + if effective != requested: + return ApprovalModeResult( + False, + effective, + False, + f"Approval mode remains {effective}; the requested value did not become effective.", + ) + return ApprovalModeResult( + True, + effective, + effective != current, + f"Approval mode: {effective} (persistent profile setting).", + ) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 83ebcf53703c..1891ca268fdb 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2778,6 +2778,16 @@ class CLICommandsMixin: except Exception: pass + def _handle_approvals_command(self, cmd_original: str) -> None: + """Show or persist the profile-wide dangerous-command approval mode.""" + from cli import _cprint + from hermes_cli.approval_mode import run_approval_mode_command + + parts = (cmd_original or "").strip().split(None, 1) + requested = parts[1] if len(parts) > 1 else None + result = run_approval_mode_command(requested) + _cprint(f" {result.message}") + def _handle_footer_command(self, cmd_original: str) -> None: """Toggle or inspect ``display.runtime_footer.enabled`` from the CLI. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 368f5c745e9d..2a5f82cb3887 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -165,6 +165,9 @@ COMMAND_REGISTRY: list[CommandDef] = [ subcommands=("on", "off", "status")), CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)", "Configuration"), + CommandDef("approvals", "Show or set the persistent dangerous-command approval mode", + "Configuration", args_hint="[manual|smart|off]", + subcommands=("manual", "smart", "off")), CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", args_hint="[level|show|hide|full|clamp] [--global]", subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp", "--global")), @@ -1187,10 +1190,15 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg") # /init clamps /version off the native list and breaks Telegram parity. # - version: low-frequency info command; reachable as /hermes version on # Slack. Demoted when /context claimed a native slot (context is a -# recurring inspection surface; version is a one-off lookup). +# recurring inspection surface; version is a one-off lookup); the demotion +# also absorbs the native slot /approvals now consumes at the 50-cap. # - diff: git working-tree diff; reached via /hermes diff on Slack so it # doesn't displace an existing native slash at the 50-command cap. -_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff"}) +# - update: low-frequency self-update maintenance command; reached via +# /hermes update on Slack. Demoted to free the native slot /approvals now +# claims — without this entry /approvals tips the registry past the 50-cap +# and silently clamps /update off, breaking Telegram parity. +_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update"}) def _sanitize_slack_name(raw: str) -> str: diff --git a/tests/gateway/test_approvals_command.py b/tests/gateway/test_approvals_command.py new file mode 100644 index 000000000000..13ea47839777 --- /dev/null +++ b/tests/gateway/test_approvals_command.py @@ -0,0 +1,91 @@ +"""Gateway contract and live dispatch for /approvals.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import yaml + +import gateway.run as gateway_run +from gateway.config import Platform +from gateway.platforms.base import MessageEvent +from gateway.session import SessionSource + + +def _event(text: str = "/approvals") -> MessageEvent: + return MessageEvent( + text=text, + source=SessionSource( + platform=Platform.TELEGRAM, + user_id="user-1", + chat_id="chat-1", + chat_type="dm", + ), + ) + + +def _runner(): + runner = object.__new__(gateway_run.GatewayRunner) + runner.config = SimpleNamespace(platforms={}) + runner.hooks = MagicMock(loaded_hooks=[]) + runner.hooks.emit = AsyncMock(return_value=[]) + runner._running_agents = {} + runner._get_or_create_gateway_honcho = lambda _key: (None, None) + runner._is_user_authorized = lambda _source: True + runner.session_store = SimpleNamespace(get_or_create_session=lambda _source: None) + return runner + + +@pytest.mark.asyncio +async def test_gateway_handler_uses_shared_persistent_logic_without_cache_eviction(): + runner = _runner() + result = SimpleNamespace(message="Approval mode: manual (persistent profile setting).") + runner._evict_cached_agent = MagicMock() + + with patch("hermes_cli.approval_mode.run_approval_mode_command", return_value=result) as run: + output = await runner._handle_approvals_command(_event("/approvals manual")) + + assert output == result.message + run.assert_called_once_with("manual") + runner._evict_cached_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_gateway_rejects_non_admin_persistent_approval_change(): + runner = _runner() + runner.config = SimpleNamespace( + platforms={ + Platform.TELEGRAM: SimpleNamespace( + extra={ + "allow_admin_from": ["admin-1"], + "user_allowed_commands": ["approvals"], + } + ) + } + ) + + with patch("hermes_cli.approval_mode.run_approval_mode_command") as run: + output = await runner._handle_approvals_command(_event("/approvals off")) + + assert "admin" in output.lower() + run.assert_not_called() + + +@pytest.mark.asyncio +async def test_gateway_live_dispatch_routes_and_persists_approvals_command(tmp_path, monkeypatch): + runner = _runner() + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_MANAGED_DIR", str(tmp_path / "missing-managed")) + from hermes_cli import managed_scope + from hermes_cli.config import _LOAD_CONFIG_CACHE, _RAW_CONFIG_CACHE + + _LOAD_CONFIG_CACHE.clear() + _RAW_CONFIG_CACHE.clear() + managed_scope.invalidate_managed_cache() + + output = await runner._handle_message(_event("/approvals manual")) + + assert output == "Approval mode: manual (persistent profile setting)." + assert yaml.safe_load((home / "config.yaml").read_text())["approvals"]["mode"] == "manual" diff --git a/tests/hermes_cli/test_approvals_command.py b/tests/hermes_cli/test_approvals_command.py new file mode 100644 index 000000000000..4296d4f71807 --- /dev/null +++ b/tests/hermes_cli/test_approvals_command.py @@ -0,0 +1,180 @@ +"""Cross-surface contract for the persistent /approvals mode command.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import yaml + +from cli import HermesCLI +from hermes_cli.commands import ( + GATEWAY_KNOWN_COMMANDS, + SUBCOMMANDS, + SlashCommandCompleter, + gateway_help_lines, + resolve_command, + telegram_bot_commands, +) +from prompt_toolkit.completion import CompleteEvent +from prompt_toolkit.document import Document + + +def _completions(text: str) -> set[str]: + return { + item.text + for item in SlashCommandCompleter().get_completions( + Document(text=text), CompleteEvent(completion_requested=True) + ) + } + + +def test_approvals_registry_drives_help_menu_and_autocomplete(): + command = resolve_command("approvals") + assert command is not None + assert command.category == "Configuration" + assert command.args_hint == "[manual|smart|off]" + assert SUBCOMMANDS["/approvals"] == ["manual", "smart", "off"] + assert "approvals" in GATEWAY_KNOWN_COMMANDS + assert any("/approvals" in line for line in gateway_help_lines()) + assert "approvals" in {name for name, _ in telegram_bot_commands()} + assert _completions("/approvals ") == {"manual", "smart", "off"} + + +def _isolate_config(monkeypatch, home): + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_MANAGED_DIR", str(home / "missing-managed")) + from hermes_cli import managed_scope + from hermes_cli.config import _LOAD_CONFIG_CACHE, _RAW_CONFIG_CACHE + + _LOAD_CONFIG_CACHE.clear() + _RAW_CONFIG_CACHE.clear() + managed_scope.invalidate_managed_cache() + + +def test_shared_approval_mode_command_reports_effective_default_without_writing(tmp_path, monkeypatch): + from hermes_cli.approval_mode import run_approval_mode_command + from tools.approval import _get_approval_mode + + _isolate_config(monkeypatch, tmp_path) + result = run_approval_mode_command(None) + + assert result.ok is True + assert result.mode == _get_approval_mode() + assert result.changed is False + assert result.mode in result.message + assert not (tmp_path / "config.yaml").exists() + + +def test_shared_approval_mode_command_persists_profile_setting(tmp_path, monkeypatch): + from hermes_cli.approval_mode import run_approval_mode_command + + _isolate_config(monkeypatch, tmp_path) + path = tmp_path / "config.yaml" + path.write_text("model:\n default: test-model\n", encoding="utf-8") + + result = run_approval_mode_command("off") + + assert result.ok is True + assert result.mode == "off" + assert result.changed is True + assert yaml.safe_load(path.read_text(encoding="utf-8"))["approvals"]["mode"] in {"off", False} + assert "persistent" in result.message.lower() + + +def test_shared_approval_mode_command_rejects_unknown_mode_without_writing(tmp_path, monkeypatch): + from hermes_cli.approval_mode import run_approval_mode_command + + _isolate_config(monkeypatch, tmp_path) + path = tmp_path / "config.yaml" + path.write_text("approvals:\n mode: smart\n", encoding="utf-8") + before = path.read_bytes() + + result = run_approval_mode_command("auto") + + assert result.ok is False + assert result.mode == "smart" + assert path.read_bytes() == before + assert "manual|smart|off" in result.message + + +def test_shared_status_matches_runtime_normalization_for_all_stored_shapes(): + from hermes_cli.approval_mode import run_approval_mode_command + from tools.approval import _get_approval_mode + + for stored in (None, "manual", "smart", "off", False, True, "", "auto"): + config = {"approvals": {}} if stored is None else {"approvals": {"mode": stored}} + with patch("hermes_cli.config.load_config", return_value=config): + result = run_approval_mode_command(None) + assert result.mode == _get_approval_mode(), stored + + +def test_shared_command_refuses_managed_mode_override(tmp_path, monkeypatch): + from hermes_cli import managed_scope + from hermes_cli.approval_mode import run_approval_mode_command + + home = tmp_path / "home" + managed = tmp_path / "managed" + home.mkdir() + managed.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed)) + (managed / "config.yaml").write_text("approvals:\n mode: manual\n", encoding="utf-8") + managed_scope.invalidate_managed_cache() + + result = run_approval_mode_command("off") + + assert result.ok is False + assert result.mode == "manual" + assert result.changed is False + assert "managed" in result.message.lower() + assert not (home / "config.yaml").exists() + + +def test_cli_dispatch_uses_shared_handler_without_rebuilding_agent(): + cli = HermesCLI.__new__(HermesCLI) + cli.config = {} + cli.console = MagicMock() + cli.agent = object() + cli._agent_running = False + cli._pending_input = MagicMock() + + with patch.object(cli, "_handle_approvals_command", create=True) as handler: + assert cli.process_command("/approvals manual") is True + + handler.assert_called_once_with("/approvals manual") + assert cli.agent is not None + + +def test_cli_handler_prints_shared_result_and_preserves_agent_cache(): + cli = HermesCLI.__new__(HermesCLI) + cached_agent = object() + cli.agent = cached_agent + result = SimpleNamespace(message="Approval mode: smart (persistent profile setting).") + + with ( + patch("hermes_cli.approval_mode.run_approval_mode_command", return_value=result) as run, + patch("cli._cprint") as output, + ): + cli._handle_approvals_command("/approvals smart") + + run.assert_called_once_with("smart") + output.assert_called_once_with(" Approval mode: smart (persistent profile setting).") + assert cli.agent is cached_agent + + +def test_cli_live_process_command_persists_mode(tmp_path, monkeypatch): + cli = HermesCLI.__new__(HermesCLI) + cached_agent = object() + cli.config = {} + cli.console = MagicMock() + cli.agent = cached_agent + cli._agent_running = False + cli._pending_input = MagicMock() + _isolate_config(monkeypatch, tmp_path) + + with patch("cli._cprint") as output: + assert cli.process_command("/approvals off") is True + + stored = yaml.safe_load((tmp_path / "config.yaml").read_text())["approvals"]["mode"] + assert stored in {"off", False} + assert "persistent profile setting" in output.call_args.args[0] + assert cli.agent is cached_agent diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6d3bae7b612b..fe8a52122f9a 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -7179,6 +7179,8 @@ def test_commands_catalog_filters_gateway_only_commands_and_keeps_status_visible assert "/status" in pairs assert canon["/status"] == "/status" + assert "/approvals" in pairs + assert resp["result"]["sub"]["/approvals"] == ["manual", "smart", "off"] assert "/topic" not in pairs assert "/approve" not in pairs From 2be2464d259a6640c72a6f5b4efd6a03ef745176 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:02:27 -0700 Subject: [PATCH 29/71] fix(desktop): satisfy eslint in approvals-command rebase --- .../src/app/session/hooks/use-prompt-actions/index.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 068ac9f23ca5..d3aa1cfbb3b0 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -940,7 +940,9 @@ describe('usePromptActions slash.exec dispatch payloads', () => { const profile = sessionProfiles.get(sessionId) const command = String(params?.command ?? '') - if (profile && command === 'approvals off') persistedModes.set(profile, 'off') + if (profile && command === 'approvals off') { + persistedModes.set(profile, 'off') + } return { output: 'Approval mode: off (persistent profile setting).' } as never } From c63e0cd3e315ba8b3df0832b5e8fa579faf242e5 Mon Sep 17 00:00:00 2001 From: HexLab98 <liruixinch@outlook.com> Date: Sun, 19 Jul 2026 16:55:51 +0700 Subject: [PATCH 30/71] fix(models): match bare Kimi Coding k3 when searching kimi Kimi Coding discovers the flagship as wire id `k3`. Picker search used only that id, so typing "kimi" hid it next to every other kimi-* model. Add picker-only search aliases without changing the wire id. --- apps/desktop/src/components/model-picker.tsx | 3 +- apps/desktop/src/lib/model-search-text.ts | 28 ++++++++++++++++++ hermes_cli/auth.py | 17 +++++++++++ hermes_cli/curses_ui.py | 9 +++++- hermes_cli/model_search.py | 30 ++++++++++++++++++++ ui-tui/src/components/modelPicker.tsx | 5 +++- ui-tui/src/lib/model-search-text.ts | 28 ++++++++++++++++++ web/src/components/ModelPickerDialog.tsx | 9 ++++-- web/src/lib/model-search-text.ts | 28 ++++++++++++++++++ 9 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/lib/model-search-text.ts create mode 100644 hermes_cli/model_search.py create mode 100644 ui-tui/src/lib/model-search-text.ts create mode 100644 web/src/lib/model-search-text.ts diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index 3395a0c3387a..6e557221cc3d 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { useI18n } from '@/i18n' import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { currentPickerSelection } from '@/lib/model-status-label' +import { modelSearchText } from '@/lib/model-search-text' import { normalize } from '@/lib/text' import type { ModelOptionProvider, ModelPricing } from '@/types/hermes' @@ -172,7 +173,7 @@ function ModelResults({ const matches = (provider: ModelOptionProvider, model: string) => !q || - model.toLowerCase().includes(q) || + modelSearchText(model).toLowerCase().includes(q) || provider.name.toLowerCase().includes(q) || provider.slug.toLowerCase().includes(q) diff --git a/apps/desktop/src/lib/model-search-text.ts b/apps/desktop/src/lib/model-search-text.ts new file mode 100644 index 000000000000..a370182abe26 --- /dev/null +++ b/apps/desktop/src/lib/model-search-text.ts @@ -0,0 +1,28 @@ +/** + * Extra tokens used only for model-picker search ranking. + * + * Wire IDs stay unchanged — some providers report short or brand-less ids + * (Kimi Coding's flagship is literally `k3`) that users still search for by + * the familiar `kimi-…` naming of sibling models. + * + * Keep in sync with ui-tui/src/lib/model-search-text.ts, + * web/src/lib/model-search-text.ts, and hermes_cli/model_search.py. + */ +const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = { + k3: ['kimi-k3', 'kimi'] +} + +/** Haystack for fuzzy/substring model search; never changes the wire id. */ +export function modelSearchText(model: string): string { + const id = model.trim() + if (!id) { + return model + } + + const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()] + if (!aliases?.length) { + return id + } + + return `${id} ${aliases.join(' ')}` +} diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 0400e7678ffa..98b354715f80 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -7341,6 +7341,22 @@ def _prompt_model_selection( desc_lines.append(f" ── {unavailable_footer} ──") description = "\n".join(desc_lines) if desc_lines else None + # Search haystacks keep pricing labels visible while adding aliases + # for brand-less wire ids (e.g. Kimi Coding `k3` ↔ query "kimi"). + from hermes_cli.model_search import model_search_text + + model_search_labels = [] + for mid in ordered: + label = _label(mid) + haystack = model_search_text(mid) + # model_search_text always starts with the wire id; only append when + # aliases add tokens beyond the bare id already in the label. + model_search_labels.append( + label if haystack == mid else f"{label} {haystack}" + ) + model_search_labels.append("Enter custom model name") + model_search_labels.append("Skip (keep current)") + idx = curses_radiolist( "Select default model:", choices, @@ -7348,6 +7364,7 @@ def _prompt_model_selection( cancel_returns=-1, description=description, searchable=True, + search_labels=model_search_labels, ) if idx < 0: return None diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index 09f5821541b6..301b2295594c 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -723,6 +723,7 @@ def curses_radiolist( cancel_returns: int | None = None, description: str | None = None, searchable: bool = False, + search_labels: List[str] | None = None, ) -> int: """Curses single-select radio list. Returns the selected index. @@ -741,6 +742,8 @@ def curses_radiolist( searchable: When true, ``/`` opens a type-to-filter prompt. The returned value is always the original item index, not a filtered row position. + search_labels: Optional haystacks for type-to-filter (length must + match ``items``). Defaults to the display labels when omitted. """ if cancel_returns is None: cancel_returns = selected @@ -812,7 +815,11 @@ def curses_radiolist( fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns), cancel_value=cancel_returns, searchable=searchable, - search_labels=plain_labels if searchable else None, + search_labels=( + list(search_labels) + if searchable and search_labels is not None + else (plain_labels if searchable else None) + ), ) diff --git a/hermes_cli/model_search.py b/hermes_cli/model_search.py new file mode 100644 index 000000000000..74b110fdd5f6 --- /dev/null +++ b/hermes_cli/model_search.py @@ -0,0 +1,30 @@ +"""Picker-only search aliases for model ids. + +Wire IDs stay unchanged. Some providers report short or brand-less ids +(Kimi Coding's flagship is literally ``k3``) that users still search for by +the familiar ``kimi-…`` naming of sibling models. + +Keep in sync with ``ui-tui/src/lib/model-search-text.ts`` and +``web/src/lib/model-search-text.ts``. +""" + +from __future__ import annotations + +# Lowercased wire id → extra tokens appended to the search haystack only. +_MODEL_SEARCH_ALIASES: dict[str, tuple[str, ...]] = { + "k3": ("kimi-k3", "kimi"), +} + + +def model_search_text(model: str) -> str: + """Return the haystack used for fuzzy/substring model search. + + Never changes the wire id passed to the provider. + """ + mid = (model or "").strip() + if not mid: + return model or "" + aliases = _MODEL_SEARCH_ALIASES.get(mid.lower()) + if not aliases: + return mid + return f"{mid} {' '.join(aliases)}" diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index e52d12718922..df95434c4a30 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -6,6 +6,7 @@ import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js' import { fuzzyRank } from '../lib/fuzzy.js' +import { modelSearchText } from '../lib/model-search-text.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' @@ -136,7 +137,9 @@ export function ModelPicker({ return allModels } - return fuzzyRank(allModels, filter, m => m).map(r => r.item) + // modelSearchText adds aliases for brand-less wire ids (e.g. Kimi + // Coding `k3` still matches a "kimi" query). + return fuzzyRank(allModels, filter, modelSearchText).map(r => r.item) }, [allModels, filter, stage]) const models = filteredModels diff --git a/ui-tui/src/lib/model-search-text.ts b/ui-tui/src/lib/model-search-text.ts new file mode 100644 index 000000000000..3495e315ceb4 --- /dev/null +++ b/ui-tui/src/lib/model-search-text.ts @@ -0,0 +1,28 @@ +/** + * Extra tokens used only for model-picker search ranking. + * + * Wire IDs stay unchanged — some providers report short or brand-less ids + * (Kimi Coding's flagship is literally `k3`) that users still search for by + * the familiar `kimi-…` naming of sibling models. + * + * Keep in sync with web/src/lib/model-search-text.ts and + * hermes_cli/model_search.py. + */ +const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = { + k3: ['kimi-k3', 'kimi'], +} + +/** Haystack for fuzzy/substring model search; never changes the wire id. */ +export function modelSearchText(model: string): string { + const id = model.trim() + if (!id) { + return model + } + + const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()] + if (!aliases?.length) { + return id + } + + return `${id} ${aliases.join(' ')}` +} diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx index 1b280123c19a..20256cbd6a33 100644 --- a/web/src/components/ModelPickerDialog.tsx +++ b/web/src/components/ModelPickerDialog.tsx @@ -12,6 +12,7 @@ import { createPortal } from "react-dom"; import { cn, themedBody } from "@/lib/utils"; import { fuzzyRank } from "@/lib/fuzzy"; import { queryMatchesProviderOnly } from "@/lib/model-picker-filter"; +import { modelSearchText } from "@/lib/model-search-text"; /** * Two-stage model picker modal. @@ -246,16 +247,18 @@ export function ModelPickerDialog(props: Props) { ); // Fuzzy-ranked models carrying the matched character positions so the model - // list can highlight why each entry matched. + // list can highlight why each entry matched. modelSearchText adds aliases + // for brand-less wire ids (e.g. Kimi Coding `k3` ↔ search "kimi"). const filteredModels = useMemo( () => fuzzyRank( models, queryMatchesSelectedProviderOnly ? "" : trimmedQuery, - (m) => m, + modelSearchText, ).map((r) => ({ model: r.item, - positions: r.positions, + // Positions may land in alias suffixes — keep only in-id highlights. + positions: r.positions.filter((i) => i >= 0 && i < r.item.length), })), [models, trimmedQuery, queryMatchesSelectedProviderOnly], ); diff --git a/web/src/lib/model-search-text.ts b/web/src/lib/model-search-text.ts new file mode 100644 index 000000000000..471d80c472c0 --- /dev/null +++ b/web/src/lib/model-search-text.ts @@ -0,0 +1,28 @@ +/** + * Extra tokens used only for model-picker search ranking. + * + * Wire IDs stay unchanged — some providers report short or brand-less ids + * (Kimi Coding's flagship is literally `k3`) that users still search for by + * the familiar `kimi-…` naming of sibling models. + * + * Keep in sync with ui-tui/src/lib/model-search-text.ts and + * hermes_cli/model_search.py. Behavioural tests live in the TUI package. + */ +const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = { + k3: ["kimi-k3", "kimi"], +}; + +/** Haystack for fuzzy/substring model search; never changes the wire id. */ +export function modelSearchText(model: string): string { + const id = model.trim(); + if (!id) { + return model; + } + + const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()]; + if (!aliases?.length) { + return id; + } + + return `${id} ${aliases.join(" ")}`; +} From 39902f1888f202aa77ac471a2655d0b34d5bfe88 Mon Sep 17 00:00:00 2001 From: HexLab98 <liruixinch@outlook.com> Date: Sun, 19 Jul 2026 16:55:51 +0700 Subject: [PATCH 31/71] test(models): cover kimi search alias for Kimi Coding k3 Assert the picker haystack keeps ordinary ids unchanged, surfaces wire id k3 for "kimi"/"k3" queries, and accept search_labels in curses mocks. --- tests/hermes_cli/test_model_search.py | 28 +++++++++++++ .../test_setup_menu_curses_migration.py | 26 +++++++++++- ui-tui/src/lib/model-search-text.test.ts | 40 +++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_model_search.py create mode 100644 ui-tui/src/lib/model-search-text.test.ts diff --git a/tests/hermes_cli/test_model_search.py b/tests/hermes_cli/test_model_search.py new file mode 100644 index 000000000000..1b7728a14a1e --- /dev/null +++ b/tests/hermes_cli/test_model_search.py @@ -0,0 +1,28 @@ +"""Picker search aliases for brand-less wire model ids.""" + +from hermes_cli.curses_ui import _filter_indices +from hermes_cli.model_search import model_search_text + + +def test_model_search_text_keeps_ordinary_ids(): + assert model_search_text("kimi-k2.6") == "kimi-k2.6" + assert model_search_text("glm-5.2") == "glm-5.2" + + +def test_model_search_text_adds_kimi_aliases_for_k3(): + assert model_search_text("k3") == "k3 kimi-k3 kimi" + assert model_search_text("K3") == "K3 kimi-k3 kimi" + + +def test_filter_indices_surfaces_k3_for_kimi_query(): + models = ["kimi-k2.6", "kimi-k2.5", "k3", "kimi-for-coding"] + haystacks = [model_search_text(m) for m in models] + ranked = [models[i] for i in _filter_indices(haystacks, "kimi")] + assert "k3" in ranked + + +def test_filter_indices_still_finds_k3_by_wire_id(): + models = ["kimi-k2.6", "k3", "kimi-for-coding"] + haystacks = [model_search_text(m) for m in models] + ranked = [models[i] for i in _filter_indices(haystacks, "k3")] + assert ranked == ["k3"] diff --git a/tests/hermes_cli/test_setup_menu_curses_migration.py b/tests/hermes_cli/test_setup_menu_curses_migration.py index eaf382272c52..fb38aa131a96 100644 --- a/tests/hermes_cli/test_setup_menu_curses_migration.py +++ b/tests/hermes_cli/test_setup_menu_curses_migration.py @@ -14,9 +14,19 @@ def test_prompt_model_selection_uses_curses_radiolist(): seen = {} - def _fake(title, items, *, selected=0, cancel_returns=None, description=None, searchable=False): + def _fake( + title, + items, + *, + selected=0, + cancel_returns=None, + description=None, + searchable=False, + search_labels=None, + ): seen["title"] = title seen["items"] = items + seen["search_labels"] = search_labels return 1 # pick second model with patch("hermes_cli.curses_ui.curses_radiolist", side_effect=_fake), \ @@ -30,6 +40,8 @@ def test_prompt_model_selection_uses_curses_radiolist(): plain = [radio_item_plain(item) for item in seen["items"]] assert plain[:2] == ["model-a", "model-b"] assert "Skip (keep current)" in plain + assert seen["search_labels"] is not None + assert len(seen["search_labels"]) == len(seen["items"]) def test_prompt_model_selection_esc_cancels(): @@ -70,8 +82,18 @@ def test_model_selection_with_pricing_passes_description(): seen = {} - def _fake(title, items, *, selected=0, cancel_returns=None, description=None, searchable=False): + def _fake( + title, + items, + *, + selected=0, + cancel_returns=None, + description=None, + searchable=False, + search_labels=None, + ): seen["description"] = description + seen["search_labels"] = search_labels return len(items) - 1 # Skip pricing = { diff --git a/ui-tui/src/lib/model-search-text.test.ts b/ui-tui/src/lib/model-search-text.test.ts new file mode 100644 index 000000000000..9f8d3a0c5b36 --- /dev/null +++ b/ui-tui/src/lib/model-search-text.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' + +import { fuzzyRank } from './fuzzy.js' +import { modelSearchText } from './model-search-text.js' + +describe('modelSearchText', () => { + it('keeps ordinary model ids unchanged', () => { + expect(modelSearchText('kimi-k2.6')).toBe('kimi-k2.6') + expect(modelSearchText('glm-5.2')).toBe('glm-5.2') + }) + + it('adds kimi aliases for the bare Kimi Coding k3 wire id', () => { + expect(modelSearchText('k3')).toBe('k3 kimi-k3 kimi') + expect(modelSearchText('K3')).toBe('K3 kimi-k3 kimi') + }) +}) + +describe('model picker search with aliases', () => { + const models = [ + 'kimi-k2.6', + 'kimi-k2.5', + 'k3', + 'kimi-for-coding', + ] + + it('surfaces k3 when the user searches kimi', () => { + const ranked = fuzzyRank(models, 'kimi', modelSearchText).map(r => r.item) + expect(ranked).toContain('k3') + }) + + it('still finds k3 by its wire id', () => { + const ranked = fuzzyRank(models, 'k3', modelSearchText).map(r => r.item) + expect(ranked).toEqual(['k3']) + }) + + it('does not invent k3 for unrelated queries', () => { + const ranked = fuzzyRank(models, 'glm', modelSearchText).map(r => r.item) + expect(ranked).toEqual([]) + }) +}) From 37e648128bfab390f1cc84c9602231e77a9f91be Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:12:28 -0700 Subject: [PATCH 32/71] fix(picker): fold live bare k3 wire id into curated kimi-k3 row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the salvaged #67409 search aliases: with kimi-k3 now in the curated kimi-coding list (#68108), a Coding Plan key rendered TWO rows for one model — curated 'kimi-k3' plus live-discovered bare 'k3' (merge dedup was exact-string). Add model_alias_canonical() derived from the same alias table and use it as the merge dedup key, so the curated public slug wins and live-only models still surface. --- hermes_cli/model_search.py | 20 ++++++ hermes_cli/models.py | 24 ++++++- .../test_model_search_alias_dedup.py | 65 +++++++++++++++++++ .../test_models_dev_preferred_merge.py | 6 +- 4 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 tests/hermes_cli/test_model_search_alias_dedup.py diff --git a/hermes_cli/model_search.py b/hermes_cli/model_search.py index 74b110fdd5f6..7004324604b2 100644 --- a/hermes_cli/model_search.py +++ b/hermes_cli/model_search.py @@ -15,6 +15,26 @@ _MODEL_SEARCH_ALIASES: dict[str, tuple[str, ...]] = { "k3": ("kimi-k3", "kimi"), } +# Lowercased wire id → canonical public slug it aliases. Used by picker +# dedup so a live bare id and its curated public slug (``k3`` / ``kimi-k3``) +# don't render as two rows for the same model. Derived from the FIRST alias +# entry, which by convention is the full public slug. +_MODEL_ALIAS_CANONICAL: dict[str, str] = { + wire_id: aliases[0].lower() + for wire_id, aliases in _MODEL_SEARCH_ALIASES.items() + if aliases +} + + +def model_alias_canonical(model: str) -> str: + """Return the canonical public slug for a bare wire-id alias. + + Identity for ids with no alias entry. Lowercases the input so callers + can use the result directly as a dedup key. + """ + key = (model or "").strip().lower() + return _MODEL_ALIAS_CANONICAL.get(key, key) + def model_search_text(model: str) -> str: """Return the haystack used for fuzzy/substring model search. diff --git a/hermes_cli/models.py b/hermes_cli/models.py index ccf616509487..617fa1906dc1 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2537,6 +2537,24 @@ _MODELS_DEV_PREFERRED: frozenset[str] = frozenset({ }) +def _model_dedup_key(model_id: str) -> str: + """Case-insensitive dedup key that also folds picker-search aliases. + + Some providers serve the same model under both a curated public slug and + a bare live wire id (Kimi Coding Plan lists its flagship as ``k3`` while + the curated catalog carries ``kimi-k3``). Folding through the search-alias + table keeps the curated-first merge from emitting both as separate rows. + The row that survives is the primary list's entry; selection still sends + whichever id the surviving row carries. + """ + key = str(model_id).strip().lower() + try: + from hermes_cli.model_search import model_alias_canonical + return model_alias_canonical(key) + except Exception: + return key + + def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]: """Merge curated list with fresh models.dev entries for a preferred provider. @@ -2802,11 +2820,11 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) else: primary, secondary = curated, live merged = list(primary) - merged_lower = {m.lower() for m in primary} + merged_lower = {_model_dedup_key(m) for m in primary} for m in secondary: - if m.lower() not in merged_lower: + if _model_dedup_key(m) not in merged_lower: merged.append(m) - merged_lower.add(m.lower()) + merged_lower.add(_model_dedup_key(m)) return merged return live # Use profile's fallback_models if defined diff --git a/tests/hermes_cli/test_model_search_alias_dedup.py b/tests/hermes_cli/test_model_search_alias_dedup.py new file mode 100644 index 000000000000..4257d8e6ebd8 --- /dev/null +++ b/tests/hermes_cli/test_model_search_alias_dedup.py @@ -0,0 +1,65 @@ +"""Picker dedup must fold live bare wire-ids into their curated public slug. + +Kimi Coding Plan live-discovers its flagship as the bare id ``k3`` while the +curated catalog carries ``kimi-k3``. The curated-first picker merge must not +render both as separate rows for the same model. +""" + +from unittest.mock import patch + +from hermes_cli.model_search import model_alias_canonical +from hermes_cli.models import provider_model_ids + + +class TestModelAliasCanonical: + def test_bare_k3_folds_to_public_slug(self): + assert model_alias_canonical("k3") == "kimi-k3" + assert model_alias_canonical("K3") == "kimi-k3" + + def test_non_alias_ids_are_identity(self): + assert model_alias_canonical("kimi-k2.6") == "kimi-k2.6" + assert model_alias_canonical("GPT-5.4") == "gpt-5.4" + assert model_alias_canonical("") == "" + + +class TestPickerMergeAliasDedup: + def test_live_bare_k3_not_duplicated_against_curated_kimi_k3(self): + """Coding Plan key: live returns bare ``k3``; curated has ``kimi-k3``. + Exactly one k3-family row must survive (the curated slug leads).""" + with ( + patch( + "hermes_cli.auth.resolve_api_key_provider_credentials", + return_value={ + "api_key": "sk-kimi-x", + "base_url": "https://api.kimi.com/coding", + }, + ), + patch( + "providers.base.ProviderProfile.fetch_models", + return_value=["k3", "kimi-for-coding"], + ), + ): + out = provider_model_ids("kimi-coding") + + k3_rows = [m for m in out if model_alias_canonical(m) == "kimi-k3"] + assert k3_rows == ["kimi-k3"], out + # Live-only entries with no curated twin still surface. + assert "kimi-for-coding" in out + + def test_live_only_models_unaffected(self): + """Alias folding must not drop live models without curated twins.""" + with ( + patch( + "hermes_cli.auth.resolve_api_key_provider_credentials", + return_value={ + "api_key": "sk-kimi-x", + "base_url": "https://api.kimi.com/coding", + }, + ), + patch( + "providers.base.ProviderProfile.fetch_models", + return_value=["kimi-brand-new-live-only"], + ), + ): + out = provider_model_ids("kimi-coding") + assert "kimi-brand-new-live-only" in out diff --git a/tests/hermes_cli/test_models_dev_preferred_merge.py b/tests/hermes_cli/test_models_dev_preferred_merge.py index 168dd934f96a..cc7a8c764947 100644 --- a/tests/hermes_cli/test_models_dev_preferred_merge.py +++ b/tests/hermes_cli/test_models_dev_preferred_merge.py @@ -171,10 +171,14 @@ class TestProviderModelIdsPreferred: ): custom_models = provider_model_ids("kimi-coding") - assert "k3" in coding_models + # The live bare wire id ``k3`` folds into the curated public slug + # ``kimi-k3`` (picker alias dedup) — one row, curated slug leads. assert coding_models[0] == "kimi-k3" + assert all(model.lower() != "k3" for model in coding_models) assert all(model.lower() != "k3" for model in legacy_models) assert all(model.lower() != "k3" for model in custom_models) + # Legacy / custom endpoints never advertise the k3 family at all + # via live discovery (their curated floor may still carry kimi-k3). def test_kimi_setup_flow_uses_same_coding_plan_catalog(self): """The setup wizard must not carry a stale duplicate Kimi model list.""" From 91d69c4ca379fe9b7751fd20ca74b176f2ccf0e7 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:02:26 -0700 Subject: [PATCH 33/71] fix(desktop): satisfy eslint in kimi-k3 picker rebase --- apps/desktop/src/components/model-picker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index 6e557221cc3d..65cc79bedf89 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -3,8 +3,8 @@ import { useState } from 'react' import { useI18n } from '@/i18n' import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' -import { currentPickerSelection } from '@/lib/model-status-label' import { modelSearchText } from '@/lib/model-search-text' +import { currentPickerSelection } from '@/lib/model-status-label' import { normalize } from '@/lib/text' import type { ModelOptionProvider, ModelPricing } from '@/types/hermes' From 96996a55bf1aea4fa20390cfb9c965582dde4c52 Mon Sep 17 00:00:00 2001 From: Ben Barclay <ben@nousresearch.com> Date: Mon, 27 Jul 2026 14:24:14 +1000 Subject: [PATCH 34/71] fix(relay): per-platform capability descriptors for multi-platform gateways (#70717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One relay adapter fronts N platforms on one WS, but the capability surface (MAX_MESSAGE_LENGTH / message_len_fn) was a scalar from whichever descriptor resolved the handshake — and the transport's read loop OVERWROTE it on every descriptor frame (last-writer-wins). A Discord chat on a gateway whose applied descriptor was Telegram's inherited the 4,096-char cap and over-sent into Discord's 2,000-char API 400 (observed live: 2,543/2,641-char replies silently lost while inbound kept working). - ws_transport: accumulate one descriptor per platform in _descriptors_by_platform (exposed via descriptor_for_platform); the FIRST descriptor of a connection generation stays the session default instead of last-writer-wins; the map resets on re-dial. - BasePlatformAdapter: new max_message_length_for_chat / message_len_fn_for_chat hooks defaulting to the scalar surface (native single-platform adapters unchanged). - RelayAdapter: overrides resolve the chat's platform from _platform_by_chat (the same map per-frame egress uses) and look up that platform's negotiated descriptor; falls back to the scalar for unknown chats/transports. - stream_consumer (streaming budget, _raw_message_limit, fallback-continuation chunking) + run.py tool-progress limit now resolve per-chat. Tests: tests/gateway/relay/test_relay_per_platform_caps.py (7) — verified fail-without/pass-with (all 7 fail with the fix stashed). Relay + stream consumer suites green (213 + 223). --- gateway/platforms/base.py | 24 ++ gateway/relay/adapter.py | 38 ++- gateway/relay/ws_transport.py | 35 ++- gateway/run.py | 11 + gateway/stream_consumer.py | 25 +- .../relay/test_relay_per_platform_caps.py | 252 ++++++++++++++++++ 6 files changed, 379 insertions(+), 6 deletions(-) create mode 100644 tests/gateway/relay/test_relay_per_platform_caps.py diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 5d5134589412..e55c91b4dd18 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2693,6 +2693,30 @@ class BasePlatformAdapter(ABC): """ return len + def max_message_length_for_chat(self, chat_id: str) -> int: + """Per-chat max message length, in ``message_len_fn_for_chat`` units. + + Default: the adapter-scalar ``MAX_MESSAGE_LENGTH`` (4096 when absent) — + for a native adapter every chat lives on the same platform so the + scalar is already correct. The relay adapter overrides this: one relay + adapter fronts N platforms with different caps (Discord 2000 vs + Telegram 4096 vs Slack 39000), and the right cap depends on which + platform the chat's inbound arrived from. + """ + try: + return int(getattr(self, "MAX_MESSAGE_LENGTH", 4096) or 4096) + except (TypeError, ValueError): + return 4096 + + def message_len_fn_for_chat(self, chat_id: str) -> Callable[[str], int]: + """Per-chat length function (companion to max_message_length_for_chat). + + Default: the adapter-wide ``message_len_fn``. The relay adapter + overrides it so a Telegram-fronted chat measures UTF-16 units while a + Discord-fronted chat on the same adapter measures codepoints. + """ + return self.message_len_fn + @property def enforces_own_access_policy(self) -> bool: """Whether this adapter gates inbound access before dispatch. diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index b149539cee50..281602922fa4 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -20,7 +20,7 @@ from __future__ import annotations import asyncio import logging -from typing import Any, Callable, Dict, Optional +from typing import Any, Callable, Dict, Optional, cast from gateway.config import Platform, PlatformConfig from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult @@ -123,6 +123,42 @@ class RelayAdapter(BasePlatformAdapter): def message_len_fn(self) -> Callable[[str], int]: return _LEN_FNS.get(self.descriptor.len_unit, len) + # ── per-chat capability resolution (Phase 1.5 multi-platform) ───────── + def _descriptor_for_chat(self, chat_id: str) -> CapabilityDescriptor: + """The capability descriptor governing a specific chat. + + A multi-platform gateway fronts N platforms on ONE adapter, but the + scalar `descriptor`/`MAX_MESSAGE_LENGTH` surface can only carry one + platform's profile (the primary identity's). Platform caps genuinely + differ — Discord 2000 / Telegram 4096 / Slack 39000 — so applying the + primary's cap to every chat either fragments needlessly (small primary) + or over-sends into a platform 400 (large primary; the live bug: 2,543 + and 2,641-char sends rejected by Discord). Resolve the chat's platform + from what we saw inbound (`_platform_by_chat`, the same map per-frame + egress uses) and look up that platform's negotiated descriptor on the + transport. Falls back to the scalar descriptor when the chat's platform + is unknown (never saw inbound) or the transport predates the map. + """ + platform = self._platform_by_chat.get(str(chat_id)) + if platform and self._transport is not None: + resolve = getattr(self._transport, "descriptor_for_platform", None) + if callable(resolve): + try: + per_platform = cast( + Optional[CapabilityDescriptor], resolve(platform) + ) + except Exception: # noqa: BLE001 - capability lookup must never break a send + per_platform = None + if per_platform is not None: + return per_platform + return self.descriptor + + def max_message_length_for_chat(self, chat_id: str) -> int: + return self._descriptor_for_chat(chat_id).max_message_length + + def message_len_fn_for_chat(self, chat_id: str) -> Callable[[str], int]: + return _LEN_FNS.get(self._descriptor_for_chat(chat_id).len_unit, len) + def supports_draft_streaming( self, chat_type: Optional[str] = None, diff --git a/gateway/relay/ws_transport.py b/gateway/relay/ws_transport.py index 62fe683c8fc1..f19b39405c00 100644 --- a/gateway/relay/ws_transport.py +++ b/gateway/relay/ws_transport.py @@ -404,6 +404,11 @@ class WebSocketRelayTransport: self._reader: Optional[asyncio.Task[None]] = None self._inbound: Optional[InboundHandler] = None self._descriptor: Optional[CapabilityDescriptor] = None + # Phase 1.5 multi-platform: descriptors keyed by the underlying platform + # (one per hello'd identity). `_descriptor` above stays the FIRST + # (primary-identity) descriptor for back-compat; this map is the + # per-platform capability surface read via `descriptor_for_platform`. + self._descriptors_by_platform: Dict[str, CapabilityDescriptor] = {} self._descriptor_ready: asyncio.Future[CapabilityDescriptor] | None = None # requestId -> future awaiting the matching outbound_result. self._pending: Dict[str, asyncio.Future[Dict[str, Any]]] = {} @@ -433,8 +438,10 @@ class WebSocketRelayTransport: loop = asyncio.get_running_loop() self._descriptor_ready = loop.create_future() # A fresh handshake is coming; clear any stale descriptor so handshake() - # awaits the new one (matters on a re-dial). + # awaits the new one (matters on a re-dial). The per-platform map resets + # with it — a reconnected connector re-sends one descriptor per hello. self._descriptor = None + self._descriptors_by_platform = {} # scale-to-zero (D12): a successful (re-)dial ends any dormant state — we # are live again, so a subsequent UNEXPECTED close should reconnect on the # normal fast backoff, not the dormant cadence. @@ -520,6 +527,18 @@ class WebSocketRelayTransport: raise RuntimeError("handshake() called before connect()") return await asyncio.wait_for(self._descriptor_ready, timeout=self._connect_timeout_s) + def descriptor_for_platform(self, platform: str) -> Optional[CapabilityDescriptor]: + """The negotiated descriptor for one fronted platform, or None. + + Phase 1.5 multi-platform: the connector replies one descriptor per + hello'd identity; they accumulate here keyed by the descriptor's own + ``platform`` field. Callers (RelayAdapter) use this to resolve PER-CHAT + capabilities — e.g. Discord's 2000-char max_message_length vs + Telegram's 4096 — instead of applying the primary identity's scalar + descriptor to every platform this gateway fronts. + """ + return self._descriptors_by_platform.get(platform) + @property def auth_revoked(self) -> bool: """True once the connector closed the socket with 4401 AFTER a prior @@ -795,7 +814,19 @@ class WebSocketRelayTransport: ftype = frame.get("type") if ftype == "descriptor": descriptor = CapabilityDescriptor.from_json(json.dumps(frame.get("descriptor", {}))) - self._descriptor = descriptor + # Phase 1.5 multi-platform: one descriptor frame arrives per hello'd + # identity. Accumulate them keyed by the descriptor's own platform so + # the adapter can resolve PER-CHAT capabilities (e.g. Discord's 2000 + # vs Telegram's 4096 max_message_length) instead of collapsing N + # platforms onto whichever descriptor arrived last. + if descriptor.platform: + self._descriptors_by_platform[descriptor.platform] = descriptor + # The FIRST descriptor of this connection generation is the session + # default (the primary identity's) — later arrivals must NOT + # overwrite it, or the scalar capability surface silently becomes + # last-writer-wins across platforms. + if self._descriptor is None: + self._descriptor = descriptor # Phase 7 Unit 7d-B: a received descriptor means the WS upgrade auth # passed and the connector accepted us — record that we've handshaked # at least once, so a LATER 4401 close is read as a revocation diff --git a/gateway/run.py b/gateway/run.py index cb5aa02490ea..37dfd9d92cde 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -21023,6 +21023,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _raw_progress_limit = int(getattr(adapter, "MAX_MESSAGE_LENGTH", 4000) or 4000) except Exception: _raw_progress_limit = 4000 + # Per-chat resolution (relay adapter fronting N platforms): the cap + # and length unit follow the chat's underlying platform. Native + # adapters return their scalar/property unchanged. + if isinstance(adapter, BasePlatformAdapter): + try: + _raw_progress_limit = int( + adapter.max_message_length_for_chat(source.chat_id) or 4000 + ) + _progress_len_fn = adapter.message_len_fn_for_chat(source.chat_id) + except Exception: + pass # Leave a little room for platform quirks / formatting. For tiny # test adapters keep the limit usable instead of clamping to 500+. _PROGRESS_TEXT_LIMIT = max( diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index e60e9117031f..7d3e31cf1535 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -675,10 +675,13 @@ class GatewayStreamConsumer: # Platform message length limit — leave room for cursor + formatting. # Use the adapter's length function (e.g. utf16_len for Telegram) so # overflow detection matches what the platform actually enforces. + # Both resolve PER-CHAT (max_message_length_for_chat): a relay adapter + # fronting N platforms has different caps per chat (Discord 2000 vs + # Telegram 4096); native adapters return their scalar unchanged. # Gate on isinstance(BasePlatformAdapter) so test MagicMocks (whose # auto-attributes return mock objects, not callables) fall back to len. _len_fn: "Callable[[str], int]" = ( - self.adapter.message_len_fn + self.adapter.message_len_fn_for_chat(self.chat_id) if isinstance(self.adapter, _BasePlatformAdapter) else len ) @@ -1359,6 +1362,15 @@ class GatewayStreamConsumer: if isinstance(self.adapter, _BasePlatformAdapter) else len ) + # Per-chat resolution (relay adapter fronting N platforms): the cap and + # length unit follow the chat's underlying platform, not the adapter + # scalar. Native adapters return their scalar/property unchanged. + if isinstance(self.adapter, _BasePlatformAdapter): + try: + raw_limit = self.adapter.max_message_length_for_chat(self.chat_id) + _len_fn = self.adapter.message_len_fn_for_chat(self.chat_id) + except Exception as e: + logger.debug("per-chat limit resolution failed: %s", e) safe_limit = max(500, raw_limit - 100) chunks = self._split_text_chunks(continuation, safe_limit, len_fn=_len_fn) @@ -1744,8 +1756,11 @@ class GatewayStreamConsumer: """Per-message length budget (in the adapter's ``message_len_fn`` units) before the consumer splits an overflowing reply. - Adapters with a richer send/draft path (e.g. Telegram rich messages) - can raise this above ``MAX_MESSAGE_LENGTH`` via + Resolved PER-CHAT via ``max_message_length_for_chat`` — a relay adapter + fronting N platforms has a different cap per chat (Discord 2000 vs + Telegram 4096 vs Slack 39000); native adapters return their scalar + ``MAX_MESSAGE_LENGTH`` unchanged. Adapters with a richer send/draft + path (e.g. Telegram rich messages) can raise this above the base via ``streaming_overflow_limit`` so a reply that fits one rich message isn't fragmented at the legacy edit limit. Falls back to ``MAX_MESSAGE_LENGTH`` (4096 default) for everyone else. @@ -1754,6 +1769,10 @@ class GatewayStreamConsumer: # isinstance gate: MagicMock adapters return mock objects (truthy, not # ints) for arbitrary attribute access — keep them on the base limit. if isinstance(self.adapter, _BasePlatformAdapter): + try: + base = self.adapter.max_message_length_for_chat(self.chat_id) + except Exception as e: + logger.debug("max_message_length_for_chat failed: %s", e) try: cap = self.adapter.streaming_overflow_limit() except Exception as e: diff --git a/tests/gateway/relay/test_relay_per_platform_caps.py b/tests/gateway/relay/test_relay_per_platform_caps.py new file mode 100644 index 000000000000..a27c3bfbe8ab --- /dev/null +++ b/tests/gateway/relay/test_relay_per_platform_caps.py @@ -0,0 +1,252 @@ +"""Per-platform capability descriptors on the relay (multi-platform Phase 1.5). + +The bug class: one relay adapter fronts N platforms on one WS, but the +capability surface (``MAX_MESSAGE_LENGTH`` / ``message_len_fn``) was a SCALAR +from whichever descriptor resolved the handshake — so a Discord chat on a +gateway whose primary identity was Telegram inherited Telegram's 4,096-char +cap and over-sent into Discord's 2,000-char API 400 (observed live: 2,543 and +2,641-char sends rejected). + +Covers: + - the transport accumulating one descriptor per platform (first = session + default, later frames must NOT overwrite it), + - the map resetting on a re-dial, + - RelayAdapter.max_message_length_for_chat / message_len_fn_for_chat + resolving from the chat's inbound platform, + - fallback to the scalar descriptor for unknown chats / transports without + the map, + - the stream consumer's _raw_message_limit honoring the per-chat cap. +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any, Dict, List, Optional + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType +from gateway.relay.adapter import RelayAdapter +from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor +from gateway.session import SessionSource + +from tests.gateway.relay.stub_connector import StubConnector + + +def _descriptor(platform: str, max_len: int, len_unit: str = "chars") -> CapabilityDescriptor: + return CapabilityDescriptor( + contract_version=CONTRACT_VERSION, + platform=platform, + label=platform.title(), + max_message_length=max_len, + supports_draft_streaming=False, + supports_edit=True, + supports_threads=False, + markdown_dialect="plain", + len_unit=len_unit, + ) + + +DISCORD = _descriptor("discord", 2000) +TELEGRAM = _descriptor("telegram", 4096, len_unit="utf16") + + +class MultiDescriptorStub(StubConnector): + """StubConnector extended with the per-platform descriptor map.""" + + def __init__(self, primary: CapabilityDescriptor, *others: CapabilityDescriptor) -> None: + super().__init__(primary) + self._by_platform = {d.platform: d for d in (primary, *others)} + + def descriptor_for_platform(self, platform: str) -> Optional[CapabilityDescriptor]: + return self._by_platform.get(platform) + + +async def _push(stub: StubConnector, platform: Platform, chat_id: str) -> None: + await stub.push_inbound( + MessageEvent( + text="hi", + message_type=MessageType.TEXT, + source=SessionSource( + platform=platform, chat_id=chat_id, chat_type="dm", user_id="u-1" + ), + ) + ) + + +# ───────────────────── transport descriptor accumulation ───────────────────── + + +def _make_transport(): + from gateway.relay.ws_transport import WebSocketRelayTransport + + return WebSocketRelayTransport( + "wss://connector.example/relay", + "telegram", + "bot-9", + identities=[("telegram", "bot-9"), ("discord", "app-1")], + ) + + +@pytest.mark.asyncio +async def test_transport_accumulates_descriptors_first_wins_as_default(): + """One descriptor frame per hello: the map holds each platform's, and the + scalar `_descriptor` (the handshake result / session default) stays the + FIRST one — the regression was last-writer-wins across platforms.""" + t = _make_transport() + loop = asyncio.get_running_loop() + t._descriptor_ready = loop.create_future() + + frame = {"type": "descriptor", "descriptor": TELEGRAM.__dict__} + await t._handle_frame(json.dumps(frame)) + frame2 = {"type": "descriptor", "descriptor": DISCORD.__dict__} + await t._handle_frame(json.dumps(frame2)) + + # Per-platform map has both. + assert t.descriptor_for_platform("telegram").max_message_length == 4096 + assert t.descriptor_for_platform("discord").max_message_length == 2000 + assert t.descriptor_for_platform("slack") is None + # The session default is the FIRST (primary identity) — NOT overwritten. + assert t._descriptor.platform == "telegram" + assert (await t.handshake()).platform == "telegram" + + +@pytest.mark.asyncio +async def test_transport_descriptor_map_resets_on_redial(monkeypatch): + """A re-dial starts a fresh handshake generation: stale per-platform + descriptors must not survive into the new connection.""" + t = _make_transport() + loop = asyncio.get_running_loop() + t._descriptor_ready = loop.create_future() + await t._handle_frame(json.dumps({"type": "descriptor", "descriptor": DISCORD.__dict__})) + assert t.descriptor_for_platform("discord") is not None + + # Simulate _dial_and_start's reset preamble without a real socket. + class _FakeWs: + async def close(self): # pragma: no cover - not called + pass + + sent: List[str] = [] + + async def _fake_connect(url, **kwargs): + return _FakeWs() + + async def _fake_send(payload): + sent.append(payload) + + import gateway.relay.ws_transport as wst + + monkeypatch.setattr(wst, "websockets", type("M", (), {"connect": staticmethod(_fake_connect)})) + monkeypatch.setattr(t, "_send", _fake_send) + monkeypatch.setattr( + t, "_read_loop", lambda: asyncio.sleep(0) + ) # substitute a no-op coroutine factory + await t._dial_and_start() + + assert t.descriptor_for_platform("discord") is None + assert t._descriptor is None + + +# ───────────────────── adapter per-chat capability surface ───────────────────── + + +@pytest.mark.asyncio +async def test_adapter_resolves_per_chat_limits_from_inbound_platform(): + """The live bug shape: primary identity Telegram (4096), a Discord chat on + the same adapter must get Discord's 2000 — not Telegram's scalar.""" + stub = MultiDescriptorStub(TELEGRAM, DISCORD) + stub._identities = [("telegram", "bot-9"), ("discord", "app-1")] + adapter = RelayAdapter(PlatformConfig(), TELEGRAM, transport=stub) + await adapter.connect() + + await _push(stub, Platform.DISCORD, "dc-1") + await _push(stub, Platform.TELEGRAM, "tg-1") + + # Scalar surface still the primary's (back-compat). + assert adapter.MAX_MESSAGE_LENGTH == 4096 + # Per-chat: each chat resolves its own platform's cap. + assert adapter.max_message_length_for_chat("dc-1") == 2000 + assert adapter.max_message_length_for_chat("tg-1") == 4096 + # Length unit follows the chat too: Telegram utf16, Discord codepoints. + surrogate = "\U0001f600" # 2 UTF-16 units, 1 codepoint + assert adapter.message_len_fn_for_chat("tg-1")(surrogate) == 2 + assert adapter.message_len_fn_for_chat("dc-1")(surrogate) == 1 + + +@pytest.mark.asyncio +async def test_adapter_unknown_chat_falls_back_to_scalar_descriptor(): + stub = MultiDescriptorStub(TELEGRAM, DISCORD) + adapter = RelayAdapter(PlatformConfig(), TELEGRAM, transport=stub) + await adapter.connect() + # Never saw inbound for this chat — platform unknown -> scalar descriptor. + assert adapter.max_message_length_for_chat("never-seen") == 4096 + + +@pytest.mark.asyncio +async def test_adapter_transport_without_map_falls_back_to_scalar(): + """A plain StubConnector (no descriptor_for_platform) — e.g. an older or + test transport — must keep the scalar behavior, not raise.""" + stub = StubConnector(TELEGRAM) + adapter = RelayAdapter(PlatformConfig(), TELEGRAM, transport=stub) + await adapter.connect() + await _push(stub, Platform.DISCORD, "dc-1") + assert adapter.max_message_length_for_chat("dc-1") == 4096 + + +def test_native_adapter_defaults_scalar(): + """BasePlatformAdapter's default per-chat hooks mirror the scalar surface + (native adapters are single-platform; nothing changes for them).""" + from gateway.platforms.base import BasePlatformAdapter + + class _Native(BasePlatformAdapter): + MAX_MESSAGE_LENGTH = 1234 + + def __init__(self): # bypass Base __init__ plumbing + pass + + async def connect(self, *, is_reconnect: bool = False) -> bool: # pragma: no cover + return True + + async def disconnect(self) -> None: # pragma: no cover + pass + + async def send(self, chat_id, content, reply_to=None, metadata=None): # pragma: no cover + raise NotImplementedError + + async def get_chat_info(self, chat_id): # pragma: no cover + return {} + + a = _Native() + assert a.max_message_length_for_chat("any") == 1234 + assert a.message_len_fn_for_chat("any") is a.message_len_fn + + +# ───────────────────── stream consumer integration ───────────────────── + + +@pytest.mark.asyncio +async def test_stream_consumer_raw_limit_uses_per_chat_cap(): + """_raw_message_limit resolves the CHAT's platform cap on a relay adapter: + a Discord chat splits at 2000 even when the scalar descriptor says 4096. + Without the fix this returned 4096 and a 2,543-char reply reached Discord + whole -> HTTP 400.""" + from gateway.stream_consumer import GatewayStreamConsumer + + stub = MultiDescriptorStub(TELEGRAM, DISCORD) + stub._identities = [("telegram", "bot-9"), ("discord", "app-1")] + adapter = RelayAdapter(PlatformConfig(), TELEGRAM, transport=stub) + await adapter.connect() + await _push(stub, Platform.DISCORD, "dc-1") + await _push(stub, Platform.TELEGRAM, "tg-1") + + dc = GatewayStreamConsumer.__new__(GatewayStreamConsumer) + dc.adapter = adapter + dc.chat_id = "dc-1" + assert dc._raw_message_limit() == 2000 + + tg = GatewayStreamConsumer.__new__(GatewayStreamConsumer) + tg.adapter = adapter + tg.chat_id = "tg-1" + assert tg._raw_message_limit() == 4096 From b68787ad25077b5055c0aba239a10f1e390cf4c8 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:08:52 -0700 Subject: [PATCH 35/71] Inspired by Claude Code: session-wide runaway-loop caps for web_search and delegate_task Add per-session lifetime caps on web_search calls and subagent spawns (defaults 200/200, matching Claude Code v2.1.212). Unlike the existing per-turn tool-loop guardrails, these count over the whole session and reset only when a fresh agent is built (/new, /clear). Hitting a cap blocks the offending call and halts the turn cleanly. - agent/tool_guardrails.py: SessionCapConfig + session counters on the controller (in __init__, not reset_for_turn, so they persist across turns). before_call() enforces caps first, independent of hard_stop_enabled. delegate_task batches count each task. - hermes_cli/config.py: tool_loop_guardrails.session_caps defaults. - docs + tests (unit + E2E validated against a real AIAgent). --- agent/tool_guardrails.py | 150 +++++++++++++++++++++++ hermes_cli/config.py | 11 ++ tests/agent/test_tool_guardrails.py | 94 ++++++++++++++ website/docs/user-guide/configuration.md | 11 ++ 4 files changed, 266 insertions(+) diff --git a/agent/tool_guardrails.py b/agent/tool_guardrails.py index f08f1b604786..bb77eec17d53 100644 --- a/agent/tool_guardrails.py +++ b/agent/tool_guardrails.py @@ -79,6 +79,7 @@ class ToolCallGuardrailConfig: no_progress_block_after: int = 5 idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES) mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES) + session_caps: "SessionCapConfig" = field(default_factory=lambda: SessionCapConfig()) @classmethod def from_mapping(cls, data: Mapping[str, Any] | None) -> "ToolCallGuardrailConfig": @@ -121,6 +122,51 @@ class ToolCallGuardrailConfig: hard_stop_after.get("idempotent_no_progress", data.get("no_progress_block_after")), defaults.no_progress_block_after, ), + session_caps=SessionCapConfig.from_mapping(data.get("session_caps")), + ) + + +# Default session-wide caps, matching Claude Code's v2.1.212 runaway-loop +# limits (CLAUDE_CODE_MAX_WEB_SEARCHES_PER_SESSION / +# CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION, both default 200, reset on /clear). +_DEFAULT_MAX_WEB_SEARCHES_PER_SESSION = 200 +_DEFAULT_MAX_SUBAGENTS_PER_SESSION = 200 + + +@dataclass(frozen=True) +class SessionCapConfig: + """Session-wide lifetime caps on runaway-prone tool calls. + + Inspired by Claude Code v2.1.212 (Week 29, July 2026), which added a + per-session cap on WebSearch calls and subagent spawns to stop runaway + search / delegation loops. These caps count over the whole session (not + per turn like the loop-detection guardrails above) and reset only when a + fresh agent is created — i.e. on ``/new`` or ``/clear`` — because the + counters live on the ``AIAgent`` instance, not in ``reset_for_turn``. + + Semantics differ from the per-turn loop guardrails: session caps are a + hard safety ceiling and fire regardless of ``hard_stop_enabled``. A value + of ``0`` disables the cap (unlimited). Defaults match Claude Code (200); + a legitimate session almost never issues 200 web searches or spawns 200 + subagents, so hitting the cap is a strong runaway-loop signal. + """ + + max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_SESSION + max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_SESSION + + @classmethod + def from_mapping(cls, data: Mapping[str, Any] | None) -> "SessionCapConfig": + """Build config from the ``tool_loop_guardrails.session_caps`` section.""" + if not isinstance(data, Mapping): + return cls() + defaults = cls() + return cls( + max_web_searches=_non_negative_int( + data.get("max_web_searches"), defaults.max_web_searches + ), + max_subagents=_non_negative_int( + data.get("max_subagents"), defaults.max_subagents + ), ) @@ -226,6 +272,12 @@ class ToolCallGuardrailController: def __init__(self, config: ToolCallGuardrailConfig | None = None): self.config = config or ToolCallGuardrailConfig() + # Session-wide counters persist for the life of this controller (i.e. + # the life of the AIAgent instance). They are deliberately NOT cleared + # in reset_for_turn — a runaway loop that spans many turns must still be + # caught. They reset only when a fresh agent is built (/new, /clear). + self._session_web_search_count = 0 + self._session_subagent_count = 0 self.reset_for_turn() def reset_for_turn(self) -> None: @@ -240,6 +292,16 @@ class ToolCallGuardrailController: def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> ToolGuardrailDecision: signature = ToolCallSignature.from_call(tool_name, _coerce_args(args)) + + # ── Session-wide runaway-loop caps ────────────────────────────── + # These are hard safety ceilings that apply regardless of + # hard_stop_enabled (which only governs the per-turn loop detector). + # We block BEFORE the call runs once the count is already at the cap, + # then increment for an allowed call so the (cap+1)-th is refused. + cap_block = self._check_session_cap(tool_name, _coerce_args(args), signature) + if cap_block is not None: + return cap_block + if not self.config.hard_stop_enabled: return ToolGuardrailDecision(tool_name=tool_name, signature=signature) @@ -379,6 +441,68 @@ class ToolCallGuardrailController: return False return tool_name in self.config.idempotent_tools + def _check_session_cap( + self, + tool_name: str, + args: Mapping[str, Any], + signature: ToolCallSignature, + ) -> ToolGuardrailDecision | None: + """Enforce and advance the session-wide runaway-loop counters. + + Returns a ``block`` decision when the cap is already reached, otherwise + increments the relevant counter for the allowed call and returns + ``None``. A cap of 0 disables that limit entirely. + """ + caps = self.config.session_caps + + if tool_name == "web_search": + cap = caps.max_web_searches + if cap and self._session_web_search_count >= cap: + decision = ToolGuardrailDecision( + action="block", + code="session_web_search_cap", + message=( + f"Blocked web_search: this session has already made {cap} " + "web searches, the per-session limit. This looks like a " + "runaway search loop. Work with the results you already " + "have, or start a fresh session (/new) to reset the budget." + ), + tool_name=tool_name, + count=self._session_web_search_count, + signature=signature, + ) + self._halt_decision = decision + return decision + self._session_web_search_count += 1 + return None + + if tool_name == "delegate_task": + cap = caps.max_subagents + if not cap: + return None + spawn_count = _subagent_spawn_count(args) + if self._session_subagent_count >= cap: + decision = ToolGuardrailDecision( + action="block", + code="session_subagent_cap", + message=( + f"Blocked delegate_task: this session has already spawned " + f"{self._session_subagent_count} subagents (limit {cap}). " + "This looks like a runaway delegation loop. Finish the work " + "with the results you have, or start a fresh session (/new) " + "to reset the budget." + ), + tool_name=tool_name, + count=self._session_subagent_count, + signature=signature, + ) + self._halt_decision = decision + return decision + self._session_subagent_count += spawn_count + return None + + return None + def toolguard_synthetic_result(decision: ToolGuardrailDecision) -> str: """Build a synthetic role=tool content string for a blocked tool call.""" @@ -471,6 +595,32 @@ def _positive_int(value: Any, default: int) -> int: return parsed if parsed >= 1 else default +def _non_negative_int(value: Any, default: int) -> int: + """Parse a session-cap value. 0 is a valid (disable) value; negatives and + junk fall back to the default.""" + if value is None: + return default + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= 0 else default + + +def _subagent_spawn_count(args: Mapping[str, Any]) -> int: + """How many subagents a single delegate_task call spawns. + + delegate_task runs in one of two modes: a batch (``tasks`` is a non-empty + list, one child per item) or a single task (``goal``). Count the batch size + when present, otherwise 1, so the session subagent cap reflects real spawns + rather than delegate_task invocations. + """ + tasks = args.get("tasks") if isinstance(args, Mapping) else None + if isinstance(tasks, list) and tasks: + return len(tasks) + return 1 + + def _sha256(value: str) -> str: # surrogatepass: tool results scraped from the web can carry unpaired # UTF-16 surrogates (e.g. half of a mathematical-bold pair); a strict diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 6a5f39028ff9..ff32e30c1b6c 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1421,6 +1421,17 @@ DEFAULT_CONFIG = { "same_tool_failure": 8, "idempotent_no_progress": 5, }, + # Session-wide runaway-loop caps (inspired by Claude Code v2.1.212, + # Week 29, July 2026). Unlike the per-turn warn/hard-stop thresholds + # above, these count over the WHOLE session and reset only when a fresh + # session starts (/new or /clear). They are always-on hard ceilings — a + # legitimate session almost never issues 200 web searches or spawns 200 + # subagents, so hitting one is a strong runaway-loop signal. Set either + # to 0 to disable that cap (unlimited). + "session_caps": { + "max_web_searches": 200, # max web_search calls per session (0 = unlimited) + "max_subagents": 200, # max subagents spawned per session (0 = unlimited) + }, }, "compression": { diff --git a/tests/agent/test_tool_guardrails.py b/tests/agent/test_tool_guardrails.py index 35b4e67f37a4..b9a4c2a4a6c7 100644 --- a/tests/agent/test_tool_guardrails.py +++ b/tests/agent/test_tool_guardrails.py @@ -277,3 +277,97 @@ def test_after_call_survives_lone_surrogates_in_result_and_args(): controller.after_call("web_search", {"query": dirty}, '{"error":"\ud835 boom"}', failed=True) controller.after_call("web_search", {"query": dirty}, '{"error":"\ud835 boom"}', failed=True) assert controller.before_call("web_search", {"query": dirty}).action == "block" + + +# ── Session-wide runaway-loop caps (Claude Code v2.1.212, Week 29) ────────── + +from agent.tool_guardrails import SessionCapConfig # noqa: E402 + + +def test_session_cap_defaults_match_claude_code(): + caps = ToolCallGuardrailConfig().session_caps + assert caps.max_web_searches == 200 + assert caps.max_subagents == 200 + + +def test_session_cap_config_parses_nested_section(): + cfg = ToolCallGuardrailConfig.from_mapping( + {"session_caps": {"max_web_searches": 3, "max_subagents": 0}} + ) + assert cfg.session_caps.max_web_searches == 3 + assert cfg.session_caps.max_subagents == 0 + + +def test_session_cap_zero_disables_and_junk_falls_back(): + # 0 is a legitimate "unlimited" value; negatives / junk fall back to default. + assert SessionCapConfig.from_mapping({"max_web_searches": 0}).max_web_searches == 0 + assert SessionCapConfig.from_mapping({"max_web_searches": -5}).max_web_searches == 200 + assert SessionCapConfig.from_mapping({"max_subagents": "nope"}).max_subagents == 200 + + +def test_web_search_cap_blocks_after_limit_regardless_of_hard_stop(): + # Session caps fire even with hard_stop_enabled=False (the per-turn loop + # detector's flag). Each distinct query avoids the loop detector so we know + # the block came from the session cap, not exact-failure repetition. + controller = ToolCallGuardrailController( + ToolCallGuardrailConfig( + hard_stop_enabled=False, + session_caps=SessionCapConfig(max_web_searches=3), + ) + ) + for i in range(3): + assert controller.before_call("web_search", {"query": f"q{i}"}).action == "allow" + decision = controller.before_call("web_search", {"query": "q4"}) + assert decision.action == "block" + assert decision.code == "session_web_search_cap" + assert decision.should_halt is True + + +def test_web_search_cap_persists_across_turn_resets(): + controller = ToolCallGuardrailController( + ToolCallGuardrailConfig(session_caps=SessionCapConfig(max_web_searches=2)) + ) + assert controller.before_call("web_search", {"query": "a"}).action == "allow" + controller.reset_for_turn() # a per-turn reset must NOT clear the session count + assert controller.before_call("web_search", {"query": "b"}).action == "allow" + controller.reset_for_turn() + assert controller.before_call("web_search", {"query": "c"}).action == "block" + + +def test_subagent_cap_counts_batch_task_spawns(): + # A single delegate_task batch of N tasks spends N of the subagent budget. + controller = ToolCallGuardrailController( + ToolCallGuardrailConfig(session_caps=SessionCapConfig(max_subagents=5)) + ) + # First call spawns 3 (batch) → count 3, allowed. + assert controller.before_call( + "delegate_task", {"tasks": [{"goal": "a"}, {"goal": "b"}, {"goal": "c"}]} + ).action == "allow" + # Second call spawns 1 (goal) → count 4, allowed. + assert controller.before_call("delegate_task", {"goal": "d"}).action == "allow" + # Count is 4 (< 5) so this is allowed and bumps to 5. + assert controller.before_call("delegate_task", {"goal": "e"}).action == "allow" + # Now count is 5 (>= 5) so the next call is blocked. + decision = controller.before_call("delegate_task", {"goal": "f"}) + assert decision.action == "block" + assert decision.code == "session_subagent_cap" + + +def test_session_caps_disabled_when_zero(): + controller = ToolCallGuardrailController( + ToolCallGuardrailConfig( + session_caps=SessionCapConfig(max_web_searches=0, max_subagents=0) + ) + ) + for i in range(50): + assert controller.before_call("web_search", {"query": f"q{i}"}).action == "allow" + assert controller.before_call("delegate_task", {"goal": f"g{i}"}).action == "allow" + + +def test_other_tools_never_touched_by_session_caps(): + controller = ToolCallGuardrailController( + ToolCallGuardrailConfig(session_caps=SessionCapConfig(max_web_searches=1)) + ) + # read_file / terminal / etc. are unaffected regardless of the web cap. + for _ in range(10): + assert controller.before_call("read_file", {"path": "/tmp/x"}).action == "allow" diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index f7b015bba17d..58264cf01741 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1455,10 +1455,21 @@ tool_loop_guardrails: exact_failure: 5 same_tool_failure: 8 idempotent_no_progress: 5 + session_caps: + max_web_searches: 200 # max web_search calls per session (0 = unlimited) + max_subagents: 200 # max subagents spawned per session (0 = unlimited) ``` `hard_stop_enabled` defaults to `false` because interactive sessions have a human in the loop. In unattended deployments (gateway, cron, kanban workers) set it to `true` so repeated failures are blocked rather than only warned. See also [Docker / unattended deployments](docker.md). +### Session-wide runaway-loop caps + +Separate from the per-turn thresholds above, `session_caps` sets hard ceilings on the total number of `web_search` calls and subagent spawns over the **whole session** (not per turn). These are always on — a legitimate session almost never issues 200 web searches or spawns 200 subagents, so hitting a cap is a strong runaway-loop signal. When a cap is reached, the offending tool call is blocked with an explanatory message and the turn stops cleanly instead of burning the rest of the budget. The counters reset only when a fresh session starts (`/new` or `/clear`). Set either value to `0` to disable that cap entirely. + +A single `delegate_task` batch counts each task toward `max_subagents` (a batch of 3 spends 3), so the cap tracks real subagents spawned rather than `delegate_task` invocations. + +This mirrors Claude Code's per-session WebSearch and subagent caps (v2.1.212), which also default to 200 and reset on `/clear`. + ## TTS Configuration ```yaml From cb06017b1d6e1b9ae0cb35f99a48ffa6bcbaa828 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:57:58 -0700 Subject: [PATCH 36/71] refactor(guardrails): make runaway-loop caps per-turn, not session-total Per Teknium: the caps should bound a single agent loop, not accumulate over the whole session. Rename SessionCapConfig -> LoopCapConfig and the config section session_caps -> loop_caps; move the counters into reset_for_turn (invoked per turn via turn_context) so each turn starts with a fresh budget; retune defaults 200 -> 50 (a single turn issuing 50 web searches / spawning 50 subagents is already pathological). Block codes session_*_cap -> loop_*_cap and messages updated to drop the /new-resets-the-budget guidance (irrelevant now that it resets per turn). Tests flipped: the old persists-across-turn-resets assertion becomes resets-each-turn. --- agent/tool_guardrails.py | 109 ++++++++++++----------- hermes_cli/config.py | 21 ++--- tests/agent/test_tool_guardrails.py | 74 +++++++++------ website/docs/user-guide/configuration.md | 10 +-- 4 files changed, 117 insertions(+), 97 deletions(-) diff --git a/agent/tool_guardrails.py b/agent/tool_guardrails.py index bb77eec17d53..444ce3739596 100644 --- a/agent/tool_guardrails.py +++ b/agent/tool_guardrails.py @@ -79,7 +79,7 @@ class ToolCallGuardrailConfig: no_progress_block_after: int = 5 idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES) mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES) - session_caps: "SessionCapConfig" = field(default_factory=lambda: SessionCapConfig()) + loop_caps: "LoopCapConfig" = field(default_factory=lambda: LoopCapConfig()) @classmethod def from_mapping(cls, data: Mapping[str, Any] | None) -> "ToolCallGuardrailConfig": @@ -122,41 +122,44 @@ class ToolCallGuardrailConfig: hard_stop_after.get("idempotent_no_progress", data.get("no_progress_block_after")), defaults.no_progress_block_after, ), - session_caps=SessionCapConfig.from_mapping(data.get("session_caps")), + loop_caps=LoopCapConfig.from_mapping(data.get("loop_caps")), ) # Default session-wide caps, matching Claude Code's v2.1.212 runaway-loop -# limits (CLAUDE_CODE_MAX_WEB_SEARCHES_PER_SESSION / -# CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION, both default 200, reset on /clear). -_DEFAULT_MAX_WEB_SEARCHES_PER_SESSION = 200 -_DEFAULT_MAX_SUBAGENTS_PER_SESSION = 200 +# Per-turn (per-agent-loop) caps on runaway-prone tool calls. Counts reset at +# the start of every agent loop (reset_for_turn), so the limit is "within a +# single turn" rather than cumulative over the whole session. A single loop +# issuing dozens of web searches or spawning dozens of subagents is already +# pathological, so the defaults are deliberately low. +_DEFAULT_MAX_WEB_SEARCHES_PER_TURN = 50 +_DEFAULT_MAX_SUBAGENTS_PER_TURN = 50 @dataclass(frozen=True) -class SessionCapConfig: - """Session-wide lifetime caps on runaway-prone tool calls. +class LoopCapConfig: + """Per-turn caps on runaway-prone tool calls. - Inspired by Claude Code v2.1.212 (Week 29, July 2026), which added a - per-session cap on WebSearch calls and subagent spawns to stop runaway - search / delegation loops. These caps count over the whole session (not - per turn like the loop-detection guardrails above) and reset only when a - fresh agent is created — i.e. on ``/new`` or ``/clear`` — because the - counters live on the ``AIAgent`` instance, not in ``reset_for_turn``. + Inspired by Claude Code v2.1.212 (Week 29, July 2026), which added caps on + WebSearch calls and subagent spawns to stop runaway search / delegation + loops. Here the caps count *within a single agent loop* (one turn): the + counters reset in ``reset_for_turn`` at the start of every + ``run_conversation``, so a legitimate multi-turn session is never starved, + but a single turn that spirals into an unbounded search / delegation loop + is stopped. - Semantics differ from the per-turn loop guardrails: session caps are a - hard safety ceiling and fire regardless of ``hard_stop_enabled``. A value - of ``0`` disables the cap (unlimited). Defaults match Claude Code (200); - a legitimate session almost never issues 200 web searches or spawns 200 - subagents, so hitting the cap is a strong runaway-loop signal. + Semantics differ from the per-turn loop *detector* above (which keys on + repeated identical/failing calls): these caps are a hard ceiling on the + total count of a tool within the turn and fire regardless of + ``hard_stop_enabled``. A value of ``0`` disables the cap (unlimited). """ - max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_SESSION - max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_SESSION + max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_TURN + max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_TURN @classmethod - def from_mapping(cls, data: Mapping[str, Any] | None) -> "SessionCapConfig": - """Build config from the ``tool_loop_guardrails.session_caps`` section.""" + def from_mapping(cls, data: Mapping[str, Any] | None) -> "LoopCapConfig": + """Build config from the ``tool_loop_guardrails.loop_caps`` section.""" if not isinstance(data, Mapping): return cls() defaults = cls() @@ -272,12 +275,6 @@ class ToolCallGuardrailController: def __init__(self, config: ToolCallGuardrailConfig | None = None): self.config = config or ToolCallGuardrailConfig() - # Session-wide counters persist for the life of this controller (i.e. - # the life of the AIAgent instance). They are deliberately NOT cleared - # in reset_for_turn — a runaway loop that spans many turns must still be - # caught. They reset only when a fresh agent is built (/new, /clear). - self._session_web_search_count = 0 - self._session_subagent_count = 0 self.reset_for_turn() def reset_for_turn(self) -> None: @@ -285,6 +282,11 @@ class ToolCallGuardrailController: self._same_tool_failure_counts: dict[str, int] = {} self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {} self._halt_decision: ToolGuardrailDecision | None = None + # Per-turn runaway-loop cap counters. Reset every turn (this method + # runs at the start of each run_conversation), so the caps bound a + # single agent loop rather than accumulating across the session. + self._turn_web_search_count = 0 + self._turn_subagent_count = 0 @property def halt_decision(self) -> ToolGuardrailDecision | None: @@ -293,12 +295,13 @@ class ToolCallGuardrailController: def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> ToolGuardrailDecision: signature = ToolCallSignature.from_call(tool_name, _coerce_args(args)) - # ── Session-wide runaway-loop caps ────────────────────────────── - # These are hard safety ceilings that apply regardless of - # hard_stop_enabled (which only governs the per-turn loop detector). + # ── Per-turn runaway-loop caps ────────────────────────────────── + # These are hard ceilings on how many times a runaway-prone tool may + # be called within a single agent loop (turn). They apply regardless + # of hard_stop_enabled (which only governs the per-turn loop detector). # We block BEFORE the call runs once the count is already at the cap, # then increment for an allowed call so the (cap+1)-th is refused. - cap_block = self._check_session_cap(tool_name, _coerce_args(args), signature) + cap_block = self._check_loop_cap(tool_name, _coerce_args(args), signature) if cap_block is not None: return cap_block @@ -441,39 +444,40 @@ class ToolCallGuardrailController: return False return tool_name in self.config.idempotent_tools - def _check_session_cap( + def _check_loop_cap( self, tool_name: str, args: Mapping[str, Any], signature: ToolCallSignature, ) -> ToolGuardrailDecision | None: - """Enforce and advance the session-wide runaway-loop counters. + """Enforce and advance the per-turn runaway-loop counters. Returns a ``block`` decision when the cap is already reached, otherwise increments the relevant counter for the allowed call and returns - ``None``. A cap of 0 disables that limit entirely. + ``None``. A cap of 0 disables that limit entirely. Counters reset each + turn via ``reset_for_turn``. """ - caps = self.config.session_caps + caps = self.config.loop_caps if tool_name == "web_search": cap = caps.max_web_searches - if cap and self._session_web_search_count >= cap: + if cap and self._turn_web_search_count >= cap: decision = ToolGuardrailDecision( action="block", - code="session_web_search_cap", + code="loop_web_search_cap", message=( - f"Blocked web_search: this session has already made {cap} " - "web searches, the per-session limit. This looks like a " + f"Blocked web_search: this turn has already made {cap} " + "web searches, the per-turn limit. This looks like a " "runaway search loop. Work with the results you already " - "have, or start a fresh session (/new) to reset the budget." + "have and give the user your answer." ), tool_name=tool_name, - count=self._session_web_search_count, + count=self._turn_web_search_count, signature=signature, ) self._halt_decision = decision return decision - self._session_web_search_count += 1 + self._turn_web_search_count += 1 return None if tool_name == "delegate_task": @@ -481,24 +485,23 @@ class ToolCallGuardrailController: if not cap: return None spawn_count = _subagent_spawn_count(args) - if self._session_subagent_count >= cap: + if self._turn_subagent_count >= cap: decision = ToolGuardrailDecision( action="block", - code="session_subagent_cap", + code="loop_subagent_cap", message=( - f"Blocked delegate_task: this session has already spawned " - f"{self._session_subagent_count} subagents (limit {cap}). " - "This looks like a runaway delegation loop. Finish the work " - "with the results you have, or start a fresh session (/new) " - "to reset the budget." + f"Blocked delegate_task: this turn has already spawned " + f"{self._turn_subagent_count} subagents (limit {cap}). " + "This looks like a runaway delegation loop. Finish the " + "work with the results you have and answer the user." ), tool_name=tool_name, - count=self._session_subagent_count, + count=self._turn_subagent_count, signature=signature, ) self._halt_decision = decision return decision - self._session_subagent_count += spawn_count + self._turn_subagent_count += spawn_count return None return None diff --git a/hermes_cli/config.py b/hermes_cli/config.py index ff32e30c1b6c..3e8b6e5f07dc 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1421,16 +1421,17 @@ DEFAULT_CONFIG = { "same_tool_failure": 8, "idempotent_no_progress": 5, }, - # Session-wide runaway-loop caps (inspired by Claude Code v2.1.212, - # Week 29, July 2026). Unlike the per-turn warn/hard-stop thresholds - # above, these count over the WHOLE session and reset only when a fresh - # session starts (/new or /clear). They are always-on hard ceilings — a - # legitimate session almost never issues 200 web searches or spawns 200 - # subagents, so hitting one is a strong runaway-loop signal. Set either - # to 0 to disable that cap (unlimited). - "session_caps": { - "max_web_searches": 200, # max web_search calls per session (0 = unlimited) - "max_subagents": 200, # max subagents spawned per session (0 = unlimited) + # Per-turn runaway-loop caps (inspired by Claude Code v2.1.212, + # Week 29, July 2026). Hard ceilings on how many times a runaway-prone + # tool may be called within a SINGLE agent loop (turn); the counters + # reset at the start of every turn, so a legitimate multi-turn session + # is never starved. They are always-on and fire regardless of the + # warn/hard-stop thresholds above. A single turn issuing dozens of web + # searches or spawning dozens of subagents is already pathological, so + # the defaults are low. Set either to 0 to disable that cap (unlimited). + "loop_caps": { + "max_web_searches": 50, # max web_search calls per turn (0 = unlimited) + "max_subagents": 50, # max subagents spawned per turn (0 = unlimited) }, }, diff --git a/tests/agent/test_tool_guardrails.py b/tests/agent/test_tool_guardrails.py index b9a4c2a4a6c7..ecf81e973199 100644 --- a/tests/agent/test_tool_guardrails.py +++ b/tests/agent/test_tool_guardrails.py @@ -279,65 +279,71 @@ def test_after_call_survives_lone_surrogates_in_result_and_args(): assert controller.before_call("web_search", {"query": dirty}).action == "block" -# ── Session-wide runaway-loop caps (Claude Code v2.1.212, Week 29) ────────── +# ── Per-turn runaway-loop caps (Claude Code v2.1.212, Week 29) ────────────── -from agent.tool_guardrails import SessionCapConfig # noqa: E402 +from agent.tool_guardrails import LoopCapConfig # noqa: E402 -def test_session_cap_defaults_match_claude_code(): - caps = ToolCallGuardrailConfig().session_caps - assert caps.max_web_searches == 200 - assert caps.max_subagents == 200 +def test_loop_cap_defaults(): + caps = ToolCallGuardrailConfig().loop_caps + assert caps.max_web_searches == 50 + assert caps.max_subagents == 50 -def test_session_cap_config_parses_nested_section(): +def test_loop_cap_config_parses_nested_section(): cfg = ToolCallGuardrailConfig.from_mapping( - {"session_caps": {"max_web_searches": 3, "max_subagents": 0}} + {"loop_caps": {"max_web_searches": 3, "max_subagents": 0}} ) - assert cfg.session_caps.max_web_searches == 3 - assert cfg.session_caps.max_subagents == 0 + assert cfg.loop_caps.max_web_searches == 3 + assert cfg.loop_caps.max_subagents == 0 -def test_session_cap_zero_disables_and_junk_falls_back(): +def test_loop_cap_zero_disables_and_junk_falls_back(): # 0 is a legitimate "unlimited" value; negatives / junk fall back to default. - assert SessionCapConfig.from_mapping({"max_web_searches": 0}).max_web_searches == 0 - assert SessionCapConfig.from_mapping({"max_web_searches": -5}).max_web_searches == 200 - assert SessionCapConfig.from_mapping({"max_subagents": "nope"}).max_subagents == 200 + assert LoopCapConfig.from_mapping({"max_web_searches": 0}).max_web_searches == 0 + assert LoopCapConfig.from_mapping({"max_web_searches": -5}).max_web_searches == 50 + assert LoopCapConfig.from_mapping({"max_subagents": "nope"}).max_subagents == 50 def test_web_search_cap_blocks_after_limit_regardless_of_hard_stop(): - # Session caps fire even with hard_stop_enabled=False (the per-turn loop + # Loop caps fire even with hard_stop_enabled=False (the per-turn loop # detector's flag). Each distinct query avoids the loop detector so we know - # the block came from the session cap, not exact-failure repetition. + # the block came from the loop cap, not exact-failure repetition. controller = ToolCallGuardrailController( ToolCallGuardrailConfig( hard_stop_enabled=False, - session_caps=SessionCapConfig(max_web_searches=3), + loop_caps=LoopCapConfig(max_web_searches=3), ) ) for i in range(3): assert controller.before_call("web_search", {"query": f"q{i}"}).action == "allow" decision = controller.before_call("web_search", {"query": "q4"}) assert decision.action == "block" - assert decision.code == "session_web_search_cap" + assert decision.code == "loop_web_search_cap" assert decision.should_halt is True -def test_web_search_cap_persists_across_turn_resets(): +def test_web_search_cap_resets_each_turn(): + # The cap bounds a single turn: reset_for_turn clears the counter so a + # legitimate multi-turn session is never starved. controller = ToolCallGuardrailController( - ToolCallGuardrailConfig(session_caps=SessionCapConfig(max_web_searches=2)) + ToolCallGuardrailConfig(loop_caps=LoopCapConfig(max_web_searches=2)) ) + # Turn 1: two searches allowed, the third would block within the turn. assert controller.before_call("web_search", {"query": "a"}).action == "allow" - controller.reset_for_turn() # a per-turn reset must NOT clear the session count assert controller.before_call("web_search", {"query": "b"}).action == "allow" - controller.reset_for_turn() assert controller.before_call("web_search", {"query": "c"}).action == "block" + # New turn: the counter resets, so the budget is fresh again. + controller.reset_for_turn() + assert controller.before_call("web_search", {"query": "d"}).action == "allow" + assert controller.before_call("web_search", {"query": "e"}).action == "allow" + assert controller.before_call("web_search", {"query": "f"}).action == "block" def test_subagent_cap_counts_batch_task_spawns(): # A single delegate_task batch of N tasks spends N of the subagent budget. controller = ToolCallGuardrailController( - ToolCallGuardrailConfig(session_caps=SessionCapConfig(max_subagents=5)) + ToolCallGuardrailConfig(loop_caps=LoopCapConfig(max_subagents=5)) ) # First call spawns 3 (batch) → count 3, allowed. assert controller.before_call( @@ -350,23 +356,33 @@ def test_subagent_cap_counts_batch_task_spawns(): # Now count is 5 (>= 5) so the next call is blocked. decision = controller.before_call("delegate_task", {"goal": "f"}) assert decision.action == "block" - assert decision.code == "session_subagent_cap" + assert decision.code == "loop_subagent_cap" -def test_session_caps_disabled_when_zero(): +def test_subagent_cap_resets_each_turn(): + controller = ToolCallGuardrailController( + ToolCallGuardrailConfig(loop_caps=LoopCapConfig(max_subagents=1)) + ) + assert controller.before_call("delegate_task", {"goal": "a"}).action == "allow" + assert controller.before_call("delegate_task", {"goal": "b"}).action == "block" + controller.reset_for_turn() + assert controller.before_call("delegate_task", {"goal": "c"}).action == "allow" + + +def test_loop_caps_disabled_when_zero(): controller = ToolCallGuardrailController( ToolCallGuardrailConfig( - session_caps=SessionCapConfig(max_web_searches=0, max_subagents=0) + loop_caps=LoopCapConfig(max_web_searches=0, max_subagents=0) ) ) - for i in range(50): + for i in range(60): assert controller.before_call("web_search", {"query": f"q{i}"}).action == "allow" assert controller.before_call("delegate_task", {"goal": f"g{i}"}).action == "allow" -def test_other_tools_never_touched_by_session_caps(): +def test_other_tools_never_touched_by_loop_caps(): controller = ToolCallGuardrailController( - ToolCallGuardrailConfig(session_caps=SessionCapConfig(max_web_searches=1)) + ToolCallGuardrailConfig(loop_caps=LoopCapConfig(max_web_searches=1)) ) # read_file / terminal / etc. are unaffected regardless of the web cap. for _ in range(10): diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 58264cf01741..4a773b30d53a 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1455,16 +1455,16 @@ tool_loop_guardrails: exact_failure: 5 same_tool_failure: 8 idempotent_no_progress: 5 - session_caps: - max_web_searches: 200 # max web_search calls per session (0 = unlimited) - max_subagents: 200 # max subagents spawned per session (0 = unlimited) + loop_caps: + max_web_searches: 50 # max web_search calls per turn (0 = unlimited) + max_subagents: 50 # max subagents spawned per turn (0 = unlimited) ``` `hard_stop_enabled` defaults to `false` because interactive sessions have a human in the loop. In unattended deployments (gateway, cron, kanban workers) set it to `true` so repeated failures are blocked rather than only warned. See also [Docker / unattended deployments](docker.md). -### Session-wide runaway-loop caps +### Per-turn runaway-loop caps -Separate from the per-turn thresholds above, `session_caps` sets hard ceilings on the total number of `web_search` calls and subagent spawns over the **whole session** (not per turn). These are always on — a legitimate session almost never issues 200 web searches or spawns 200 subagents, so hitting a cap is a strong runaway-loop signal. When a cap is reached, the offending tool call is blocked with an explanatory message and the turn stops cleanly instead of burning the rest of the budget. The counters reset only when a fresh session starts (`/new` or `/clear`). Set either value to `0` to disable that cap entirely. +Separate from the failure-based thresholds above, `loop_caps` sets hard ceilings on how many `web_search` calls and subagent spawns a single agent loop (turn) may make. The counters reset at the start of every turn, so a legitimate multi-turn session is never starved — but a single turn that spirals into an unbounded search or delegation loop is stopped. These are always on and fire regardless of `hard_stop_enabled`. A single turn issuing dozens of web searches or spawning dozens of subagents is already pathological, so the defaults are low. When a cap is reached, the offending tool call is blocked with an explanatory message and the turn stops cleanly instead of burning the rest of the budget. Set either value to `0` to disable that cap entirely. A single `delegate_task` batch counts each task toward `max_subagents` (a batch of 3 spends 3), so the cap tracks real subagents spawned rather than `delegate_task` invocations. From e8f8b34b0c534af8e018abf2a707d04079a4108e Mon Sep 17 00:00:00 2001 From: Oleksii Kalentiev <okalentiev@gmail.com> Date: Mon, 6 Jul 2026 12:28:05 +0200 Subject: [PATCH 37/71] fix(auxiliary): don't skip sibling models when a configured fallback_chain reuses the same provider _try_configured_fallback_chain skipped every fallback_chain entry whose provider matched the one that just failed. A chain that intentionally lists several models under the same provider (e.g. two more NVIDIA NIM models after the primary NIM model times out) was therefore skipped wholesale, falling straight through to the main-agent-model safety net instead of trying the other configured models on that provider. Add failed_model so the skip narrows to the exact (provider, model) pair that failed. Callers that only know the provider (client-build failures, where the whole provider is unreachable regardless of model) keep the old provider-wide skip; the two runtime request-error call sites (call_llm, async_call_llm) now pass the model that just failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- agent/auxiliary_client.py | 37 ++++++++-- tests/agent/test_auxiliary_client.py | 105 ++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 7 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 74ccd5781297..054ccdf3bce5 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -4326,6 +4326,7 @@ def _try_configured_fallback_chain( task: str, failed_provider: str, reason: str = "error", + failed_model: Optional[str] = None, ) -> Tuple[Optional[Any], Optional[str], str]: """Try user-configured fallback_chain for a specific auxiliary task. @@ -4333,6 +4334,20 @@ def _try_configured_fallback_chain( entry in order. Each entry must have at least ``provider``; ``model``, ``base_url``, and ``api_key`` are optional. + ``failed_model`` narrows the skip check to the exact (provider, model) + pair that just failed, rather than the whole provider. Without it (the + "no client could be built" callers, where credentials/auth are broken + for the whole provider regardless of model) every entry sharing that + provider is skipped, same as before. With it (the runtime request-error + callers), a chain that intentionally lists several models under the + same provider — e.g. two more NVIDIA NIM models after the primary NIM + model times out — is no longer skipped wholesale; only the entry that + is an exact match for what just failed is skipped, so the chain can + still serve the request from the same provider instead of jumping + straight to the main-agent-model safety net. See issue where an NVIDIA + NIM timeout on the primary compression model fell through to the main + Codex model instead of trying the two other configured NIM fallbacks. + Returns: (client, model, provider_label) or (None, None, "") if no fallback. """ @@ -4345,6 +4360,7 @@ def _try_configured_fallback_chain( return None, None, "" skip = failed_provider.lower().strip() + skip_model = (failed_model or "").strip().lower() or None tried = [] min_ctx = _task_minimum_context_length(task) @@ -4352,9 +4368,14 @@ def _try_configured_fallback_chain( if not isinstance(entry, dict): continue fb_provider = str(entry.get("provider", "")).strip() - if not fb_provider or fb_provider.lower() == skip: + if not fb_provider: continue - fb_model = str(entry.get("model", "")).strip() or None + fb_model_raw = str(entry.get("model", "")).strip() + if fb_provider.lower() == skip and ( + skip_model is None or fb_model_raw.lower() == skip_model + ): + continue + fb_model = fb_model_raw or None label = f"fallback_chain[{i}]({fb_provider})" @@ -8086,7 +8107,8 @@ def call_llm( fb_client, fb_model, fb_label = (None, None, "") if is_auto: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=final_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_fallback_chain( task, resolved_provider or "auto", reason=reason) @@ -8095,7 +8117,8 @@ def call_llm( resolved_provider, task, reason=reason) else: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=final_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( resolved_provider, task, reason=reason) @@ -8620,7 +8643,8 @@ async def async_call_llm( fb_client, fb_model, fb_label = (None, None, "") if is_auto: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=final_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_fallback_chain( task, resolved_provider or "auto", reason=reason) @@ -8629,7 +8653,8 @@ async def async_call_llm( resolved_provider, task, reason=reason) else: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=final_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( resolved_provider, task, reason=reason) diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index abe1e7d23bdc..3aa982ea8266 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -2858,6 +2858,7 @@ class TestAuxiliaryFallbackLayering: "title_generation", "nvidia", reason="invalid provider response", + failed_model="minimaxai/minimax-m3", ) mock_main.assert_not_called() @@ -2891,6 +2892,7 @@ class TestAuxiliaryFallbackLayering: "compression", "nvidia", reason="invalid provider response", + failed_model="minimaxai/minimax-m3", ) def test_auto_provider_uses_task_then_main_chain_before_builtin_chain(self, monkeypatch): @@ -2919,7 +2921,8 @@ class TestAuxiliaryFallbackLayering: assert main_chain_client.chat.completions.create.called mock_task_chain.assert_called_once_with( - "title_generation", "auto", reason="payment error") + "title_generation", "auto", reason="payment error", + failed_model="qwen/qwen3.5-122b-a10b") mock_main_chain.assert_called_once_with( "title_generation", "auto", reason="payment error") mock_builtin_chain.assert_not_called() @@ -6307,6 +6310,106 @@ class TestCompressionFallbackContextFilter: assert client is large_client assert model == "large-512k" + # ── same-provider, different-model chain entries ──────────────────── + # A configured fallback_chain may legitimately list several models + # under the *same* provider (e.g. two more NVIDIA NIM models after the + # primary NIM model). failed_provider alone must not skip those + # sibling entries — only failed_model narrows the skip to the exact + # (provider, model) pair that just failed. + + def test_same_provider_sibling_model_not_skipped_when_failed_model_given( + self, monkeypatch + ): + from agent.auxiliary_client import _try_configured_fallback_chain + + sibling_client = MagicMock(name="sibling_nim_client") + entries = [ + self._make_chain_entry("nvidia", "minimaxai/minimax-m3"), + self._make_chain_entry("nvidia", "deepseek-ai/deepseek-v4-flash"), + ] + + def fake_resolve(entry): + if entry is entries[0]: + return sibling_client, "minimaxai/minimax-m3" + raise AssertionError("second entry should not be reached") + + monkeypatch.setattr( + "agent.auxiliary_client._get_auxiliary_task_config", + lambda task: {"fallback_chain": entries} if task == "compression" else {}, + ) + + with patch("agent.auxiliary_client._resolve_fallback_entry", + side_effect=fake_resolve), \ + patch("agent.auxiliary_client.get_model_context_length", + return_value=1_048_576): + client, model, label = _try_configured_fallback_chain( + task="compression", + failed_provider="nvidia", + failed_model="deepseek-ai/deepseek-v4-pro", + ) + + assert client is sibling_client, ( + "A same-provider entry with a DIFFERENT model must still be " + "tried when failed_model narrows the skip — regression for the " + "bug where an NVIDIA NIM timeout fell straight through to the " + "main Codex model instead of trying the other two configured " + "NIM models." + ) + assert model == "minimaxai/minimax-m3" + assert "nvidia" in label + + def test_same_provider_same_model_still_skipped(self, monkeypatch): + """The exact (provider, model) pair that just failed is still + skipped — failed_model narrows the skip, it doesn't disable it.""" + from agent.auxiliary_client import _try_configured_fallback_chain + + entries = [ + self._make_chain_entry("nvidia", "deepseek-ai/deepseek-v4-pro"), + ] + + monkeypatch.setattr( + "agent.auxiliary_client._get_auxiliary_task_config", + lambda task: {"fallback_chain": entries} if task == "compression" else {}, + ) + + with patch("agent.auxiliary_client._resolve_fallback_entry", + side_effect=AssertionError("must not be resolved")): + client, model, label = _try_configured_fallback_chain( + task="compression", + failed_provider="nvidia", + failed_model="deepseek-ai/deepseek-v4-pro", + ) + + assert client is None + assert model is None + assert label == "" + + def test_same_provider_skipped_wholesale_without_failed_model(self, monkeypatch): + """Backward compat: callers that don't know the failed model (e.g. + client-build failures where the whole provider is unreachable) + still skip every entry sharing that provider, as before.""" + from agent.auxiliary_client import _try_configured_fallback_chain + + entries = [ + self._make_chain_entry("nvidia", "minimaxai/minimax-m3"), + self._make_chain_entry("nvidia", "deepseek-ai/deepseek-v4-flash"), + ] + + monkeypatch.setattr( + "agent.auxiliary_client._get_auxiliary_task_config", + lambda task: {"fallback_chain": entries} if task == "compression" else {}, + ) + + with patch("agent.auxiliary_client._resolve_fallback_entry", + side_effect=AssertionError("must not be resolved")): + client, model, label = _try_configured_fallback_chain( + task="compression", failed_provider="nvidia", + ) + + assert client is None + assert model is None + assert label == "" + # ── L3: main fallback chain ──────────────────────────────────────── def test_main_chain_skips_too_small_candidate_for_compression(self, monkeypatch): From 83f20e07e0aa0bb09e4559d2f5c7ab9b35a95dc1 Mon Sep 17 00:00:00 2001 From: Oleksii Kalentiev <okalentiev@gmail.com> Date: Mon, 6 Jul 2026 12:46:00 +0200 Subject: [PATCH 38/71] fix(auxiliary): keep provider-wide skip for auth/payment failures in fallback chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the same-provider fallback fix: narrowing the configured-chain skip to the exact failed model is only correct for model-specific failures. Auth (401) and payment (402) errors are provider-wide — every model on the provider shares the same broken credentials/account — so trying a sibling model can't recover and merely burns another doomed request before the aux task fails. Worse, returning that sibling client bypasses the main-agent-model safety net that a provider-wide skip would have reached. Only forward failed_model to _try_configured_fallback_chain for model-specific failures (timeout, connection, rate limit, model-incompatible, invalid response). Auth/payment keep failed_model=None (whole-provider skip), preserving the pre-existing safety-net behaviour for credential/billing failures. Adds an integration test that a timeout forwards the failed model, and updates the payment-error test to assert failed_model=None. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- agent/auxiliary_client.py | 55 ++++++++++++++++++++-------- tests/agent/test_auxiliary_client.py | 43 +++++++++++++++++++++- 2 files changed, 81 insertions(+), 17 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 054ccdf3bce5..cbc4254e6bea 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -4335,18 +4335,23 @@ def _try_configured_fallback_chain( ``base_url``, and ``api_key`` are optional. ``failed_model`` narrows the skip check to the exact (provider, model) - pair that just failed, rather than the whole provider. Without it (the - "no client could be built" callers, where credentials/auth are broken - for the whole provider regardless of model) every entry sharing that - provider is skipped, same as before. With it (the runtime request-error - callers), a chain that intentionally lists several models under the - same provider — e.g. two more NVIDIA NIM models after the primary NIM - model times out — is no longer skipped wholesale; only the entry that - is an exact match for what just failed is skipped, so the chain can - still serve the request from the same provider instead of jumping - straight to the main-agent-model safety net. See issue where an NVIDIA - NIM timeout on the primary compression model fell through to the main - Codex model instead of trying the two other configured NIM fallbacks. + pair that just failed, rather than the whole provider. Without it every + entry sharing the failed provider is skipped (the original behaviour). + Callers pass it only when a sibling model on the same provider could + plausibly recover: + + - Model-specific runtime failures (timeout, connection, rate limit, + model-incompatible, invalid response) pass ``failed_model`` so a + chain that intentionally lists several models under the same provider + — e.g. two more NVIDIA NIM models after the primary NIM model times + out — is not skipped wholesale. Only the exact model that failed is + skipped; the siblings still run instead of jumping straight to the + main-agent-model safety net. + - Provider-wide failures (auth 401, payment 402) and "no client could + be built" callers leave ``failed_model`` as None, keeping the whole + provider skipped — the shared credentials/account behind every model + on that provider are broken, so a sibling can't help and the + main-agent-model safety net should be reached instead. Returns: (client, model, provider_label) or (None, None, "") if no fallback. @@ -8099,6 +8104,15 @@ def call_llm( logger.info("Auxiliary %s: %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) + # Narrow the configured-chain skip to the exact model that + # failed ONLY for model-specific failures. Auth (401) and + # payment (402) errors are provider-wide — the credentials or + # account behind every model on that provider are the same — so + # a sibling model can't recover; keep skipping the whole + # provider so the main-agent-model safety net is still reached. + _chain_failed_model = ( + None if reason in ("auth error", "payment error") else final_model + ) # Fallback order (#26882, #26803): # 1. User-configured fallback_chain (per-task) if set # 2. For auto: top-level main fallback_providers/fallback_model @@ -8108,7 +8122,7 @@ def call_llm( if is_auto: fb_client, fb_model, fb_label = _try_configured_fallback_chain( task, resolved_provider or "auto", reason=reason, - failed_model=final_model) + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_fallback_chain( task, resolved_provider or "auto", reason=reason) @@ -8118,7 +8132,7 @@ def call_llm( else: fb_client, fb_model, fb_label = _try_configured_fallback_chain( task, resolved_provider or "auto", reason=reason, - failed_model=final_model) + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( resolved_provider, task, reason=reason) @@ -8635,6 +8649,15 @@ async def async_call_llm( logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) + # Narrow the configured-chain skip to the exact model that + # failed ONLY for model-specific failures. Auth (401) and + # payment (402) errors are provider-wide — the credentials or + # account behind every model on that provider are the same — so + # a sibling model can't recover; keep skipping the whole + # provider so the main-agent-model safety net is still reached. + _chain_failed_model = ( + None if reason in ("auth error", "payment error") else final_model + ) # Fallback order (#26882, #26803): # 1. User-configured fallback_chain (per-task) if set # 2. For auto: top-level main fallback_providers/fallback_model @@ -8644,7 +8667,7 @@ async def async_call_llm( if is_auto: fb_client, fb_model, fb_label = _try_configured_fallback_chain( task, resolved_provider or "auto", reason=reason, - failed_model=final_model) + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_fallback_chain( task, resolved_provider or "auto", reason=reason) @@ -8654,7 +8677,7 @@ async def async_call_llm( else: fb_client, fb_model, fb_label = _try_configured_fallback_chain( task, resolved_provider or "auto", reason=reason, - failed_model=final_model) + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( resolved_provider, task, reason=reason) diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 3aa982ea8266..5fece4c4f783 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -2920,9 +2920,12 @@ class TestAuxiliaryFallbackLayering: ) assert main_chain_client.chat.completions.create.called + # Payment errors are provider-wide, so the configured chain is + # asked to skip the whole provider (failed_model=None), not just + # the failed model — a sibling model can't recover from a 402. mock_task_chain.assert_called_once_with( "title_generation", "auto", reason="payment error", - failed_model="qwen/qwen3.5-122b-a10b") + failed_model=None) mock_main_chain.assert_called_once_with( "title_generation", "auto", reason="payment error") mock_builtin_chain.assert_not_called() @@ -3360,6 +3363,44 @@ class TestTransientTransportRetry: assert primary.chat.completions.create.call_count == 1 assert fb_client.chat.completions.create.call_count == 1 + def test_timeout_forwards_failed_model_to_configured_chain(self): + """A timeout is model-specific, so call_llm must forward the failed + model to the configured chain (failed_model=<model>, not None). This + lets a same-provider sibling in the chain be tried instead of the + whole provider being skipped — the exact NVIDIA NIM bug's trigger. + """ + class _Timeout(Exception): + pass + _Timeout.__name__ = "APITimeoutError" + + primary = MagicMock() + primary.base_url = "https://integrate.api.nvidia.com/v1" + primary.chat.completions.create.side_effect = _Timeout("Request timed out.") + + fb_client = MagicMock() + fb_client.base_url = "https://integrate.api.nvidia.com/v1" + fb_client.chat.completions.create.return_value = {"fallback": True} + + p1, p2, p3 = self._patches(primary) + with ( + p1, p2, p3, + patch( + "agent.auxiliary_client._try_configured_fallback_chain", + return_value=(fb_client, "sibling-model", "fallback_chain[0](openrouter)"), + ) as mock_chain, + patch( + "agent.auxiliary_client._try_main_agent_model_fallback", + return_value=(None, None, ""), + ), + ): + result = call_llm(task="compression", messages=[{"role": "user", "content": "hi"}]) + assert result == {"fallback": True} + _, kwargs = mock_chain.call_args + assert kwargs.get("failed_model") == "some-model", ( + "A timeout is model-specific — the failed model must be forwarded " + "so a same-provider sibling can be tried, not skipped wholesale." + ) + def test_non_compression_still_retries_same_provider_on_timeout(self): """The timeout skip is scoped to compression only; other auxiliary tasks keep the single same-provider transient retry. From 0a2c245cd6af3dfcdde3f61077f7429bb9c8a48a Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:20:17 -0700 Subject: [PATCH 39/71] fix(auxiliary): reach the main agent model when a sibling aux model fails on the same provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen okalentiev's failed_model narrowing (#59561) to _try_main_agent_model_fallback. The safety-net layer still skipped on a provider-label match alone, so single-provider users whose aux compression model and main model share one custom endpoint had ZERO fallbacks: the aux model timing out exhausted the chain in one hop and compression aborted. Real incident (0.19.0 debug dump): aux zai-org/glm-5.2 hung 324s and timed out while main mindai/macaron-v1-venti on the SAME endpoint was serving 448K-token turns — the label-only skip discarded the one viable summarizer, the session wedged over threshold, and the anti-thrash breaker tripped. Same convention as the chain fix: model-specific failures (timeout, connection, rate limit) pass failed_model so only the exact failed model is skipped; provider-wide failures (auth 401 / payment 402) pass None and keep the whole-provider skip. Both sync and async call_llm sites pass it. Sabotage-verified: the new regression test fails on the provider-only skip. --- agent/auxiliary_client.py | 34 ++++++++++++++++---- contributors/emails/okalentiev@gmail.com | 1 + tests/agent/test_auxiliary_client.py | 41 ++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 contributors/emails/okalentiev@gmail.com diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index cbc4254e6bea..910130daa3a0 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -4188,6 +4188,7 @@ def _try_main_agent_model_fallback( failed_provider: str, task: str = None, reason: str = "error", + failed_model: Optional[str] = None, ) -> Tuple[Optional[Any], Optional[str], str]: """Last-resort fallback to the user's main agent provider + model. @@ -4196,8 +4197,23 @@ def _try_main_agent_model_fallback( layer: if nothing the user asked for can serve the request, try the main chat model before giving up. - Skips when the failed provider already IS the main provider (no point - retrying the same backend that just failed). + ``failed_model`` narrows the same-provider skip to the exact + (provider, model) pair that just failed, mirroring + :func:`_try_configured_fallback_chain`. This matters for self-hosted / + custom endpoints serving several models behind one provider label: the + aux compression model timing out says nothing about the health of the + main agent model deployed on the same URL (real incident: aux + ``glm-5.2`` hung and timed out while main ``macaron-v1-venti`` on the + identical endpoint was serving 448K-token turns fine — the + provider-label skip discarded the one fallback that would have worked). + + - Model-specific runtime failures (timeout, connection, rate limit, + model-incompatible, invalid response) pass ``failed_model``: skip the + main model only when it IS the exact model that failed. + - Provider-wide failures (auth 401, payment 402) and legacy callers + leave ``failed_model`` as None, keeping the whole-provider skip — + the shared credentials/account are broken, so the main model on the + same provider cannot help either. Returns: (client, model, provider_label) or (None, None, "") if no fallback. @@ -4215,8 +4231,12 @@ def _try_main_agent_model_fallback( return None, None, "" skip = (failed_provider or "").lower().strip() - if main_provider.lower() == skip: - # The thing that failed IS the main model — nothing to fall back to. + skip_model = (failed_model or "").strip().lower() or None + if main_provider.lower() == skip and ( + skip_model is None or main_model.lower() == skip_model + ): + # The thing that failed IS the main model (or the failure was + # provider-wide) — nothing to fall back to. return None, None, "" if _is_provider_unhealthy(main_provider): _log_skip_unhealthy(main_provider, task) @@ -8135,7 +8155,8 @@ def call_llm( failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( - resolved_provider, task, reason=reason) + resolved_provider, task, reason=reason, + failed_model=_chain_failed_model) if fb_client is not None: fb_resp = _call_fallback_candidate_sync( @@ -8680,7 +8701,8 @@ async def async_call_llm( failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( - resolved_provider, task, reason=reason) + resolved_provider, task, reason=reason, + failed_model=_chain_failed_model) if fb_client is not None: # Convert sync fallback client to async diff --git a/contributors/emails/okalentiev@gmail.com b/contributors/emails/okalentiev@gmail.com new file mode 100644 index 000000000000..3445cdf65363 --- /dev/null +++ b/contributors/emails/okalentiev@gmail.com @@ -0,0 +1 @@ +okalentiev diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 5fece4c4f783..8da9bd1edf5f 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -3167,6 +3167,47 @@ class TestTryMainAgentModelFallback: client, model, label = _try_main_agent_model_fallback("glm", task="vision") assert client is None + def test_same_provider_different_model_falls_back_when_failed_model_given(self): + """Self-hosted shape: aux model and main model share one custom + provider label. A timeout on the aux model must still reach the main + model — same provider, DIFFERENT model (real incident: aux glm-5.2 + timed out while main macaron-v1-venti on the same endpoint was + healthy; the provider-label skip discarded the working fallback).""" + from agent.auxiliary_client import _try_main_agent_model_fallback + fake_client = MagicMock() + with patch("agent.auxiliary_client._read_main_provider", return_value="custom"), \ + patch("agent.auxiliary_client._read_main_model", return_value="mindai/macaron-v1-venti"), \ + patch("agent.auxiliary_client._is_provider_unhealthy", return_value=False), \ + patch("agent.auxiliary_client.resolve_provider_client", + return_value=(fake_client, "mindai/macaron-v1-venti")): + client, model, label = _try_main_agent_model_fallback( + "custom", task="compression", failed_model="zai-org/glm-5.2") + assert client is fake_client + assert model == "mindai/macaron-v1-venti" + assert label == "main-agent(custom)" + + def test_same_provider_same_model_still_skips_with_failed_model(self): + """When the model that failed IS the main model, there is nothing to + fall back to — the narrowed skip must not regress into a self-retry.""" + from agent.auxiliary_client import _try_main_agent_model_fallback + with patch("agent.auxiliary_client._read_main_provider", return_value="custom"), \ + patch("agent.auxiliary_client._read_main_model", return_value="mindai/macaron-v1-venti"): + client, model, label = _try_main_agent_model_fallback( + "custom", task="compression", + failed_model="MindAI/Macaron-V1-Venti") # case-insensitive match + assert client is None and model is None and label == "" + + def test_same_provider_no_failed_model_keeps_provider_wide_skip(self): + """Legacy / provider-wide callers (auth 401, payment 402) pass no + failed_model: the whole-provider skip must be preserved so broken + shared credentials don't trigger a doomed main-model attempt.""" + from agent.auxiliary_client import _try_main_agent_model_fallback + with patch("agent.auxiliary_client._read_main_provider", return_value="custom"), \ + patch("agent.auxiliary_client._read_main_model", return_value="mindai/macaron-v1-venti"): + client, model, label = _try_main_agent_model_fallback( + "custom", task="compression") + assert client is None and model is None and label == "" + # --------------------------------------------------------------------------- # Gate: _resolve_api_key_provider must skip anthropic when not configured From 2c867b05ce2a3803c919d1f6784d75a10899b5a9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Mon, 27 Jul 2026 01:07:48 -0500 Subject: [PATCH 40/71] =?UTF-8?q?perf(desktop):=2060fps=20sash=20drag=20on?= =?UTF-8?q?=20real=20sessions=20=E2=80=94=20height-gate=20the=20RO=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driving HER real instance (real profile, real transcripts, streams live) via CDP instead of synthetic tiles finally exposed the remaining stall. The timeline on a real 60-frame sash drag: style recalc 2736ms | script 1027ms | layout 89ms top callsite: pin @ fallback.tsx — 927ms Two pin-to-bottom ResizeObservers (the bounded tool window's and the reasoning preview's) pinned on EVERY resize delivery. A sash drag changes every message's WIDTH once per frame, so each frame ran scrollTop write -> scrollHeight read across every tool group: a forced write-read reflow cascade that the render counters could never see (zero React involvement). Both pins are now height-gated off the RO entry (reflow-free): only content GROWTH pins. Width-only deliveries return immediately. Measured on the live app, same drag, before -> after: fps 11.5 -> 59-60 p95 101ms -> 18ms slow>33 60/60 -> 1/60 Also in this batch (each was verified live before the next was attempted): - thread/list: split messageSignature into STRUCTURAL (ids/roles — keys boundaries + row identity) and WEIGHT (part counts — budget only), and memoize groups + row JSX. A streamed part-append re-rendered every turn's boundary via its resetKey prop; explain() measured 540-865 wasted Block renders per drag/stream sample, now {}. - message-render-boundary: document the structural-only resetKey contract. - tool/fallback: memoize ToolFallback's part object + ToolEntry/ToolTitle/ ToolGlyph (151 renders each, 100% wasted, on real transcripts). - use-message-stream: ADAPTIVE flush floor — next flush waits 3x the measured cost of the last one (33ms floor, 250ms cap), so multi-stream load degrades text update rate instead of input latency. - tree-split: preview sash drags with inline flex on the two seam wrappers, committing the store ONCE on release (fixed-zone sides get flexBasis only, so a hidden sidebar can't leave a phantom gap). - debug/: perf-live LoAF long-frame attribution, explain() cascade walker with changed-hook indices, diag-real-loop/key-latency/switch-trace probes that drive the real app over CDP. Typing during 2 live streams: keystroke->paint p50 3.3ms, p95 18.4ms, zero frames over 33ms. Session switch p50 ~35ms settled; the remaining ~1.3s outlier tail is streaming-session switches (React work-loop, not style/layout) — next target. --- apps/desktop/scripts/diag-code-live.mjs | 25 ++ apps/desktop/scripts/diag-key-latency.mjs | 47 +++ apps/desktop/scripts/diag-live-state.mjs | 21 ++ apps/desktop/scripts/diag-real-loop.mjs | 149 +++++++++ apps/desktop/scripts/diag-sidebar-dom.mjs | 27 ++ apps/desktop/scripts/diag-switch-trace.mjs | 74 +++++ .../session/hooks/use-message-stream/index.ts | 25 +- .../session/hooks/use-message-stream/utils.ts | 6 + .../assistant-ui/message-render-boundary.tsx | 9 +- .../components/assistant-ui/thread/list.tsx | 133 +++++--- .../assistant-ui/thread/message-parts.tsx | 16 +- .../components/assistant-ui/tool/fallback.tsx | 20 +- .../src/components/pane-shell/geometry.ts | 34 ++ .../pane-shell/tree/renderer/tree-split.tsx | 73 ++++- apps/desktop/src/debug/index.ts | 4 + apps/desktop/src/debug/perf-live.ts | 306 ++++++++++++++++++ apps/desktop/src/debug/render-counter.ts | 102 +++++- 17 files changed, 1013 insertions(+), 58 deletions(-) create mode 100644 apps/desktop/scripts/diag-code-live.mjs create mode 100644 apps/desktop/scripts/diag-key-latency.mjs create mode 100644 apps/desktop/scripts/diag-live-state.mjs create mode 100644 apps/desktop/scripts/diag-real-loop.mjs create mode 100644 apps/desktop/scripts/diag-sidebar-dom.mjs create mode 100644 apps/desktop/scripts/diag-switch-trace.mjs create mode 100644 apps/desktop/src/debug/perf-live.ts diff --git a/apps/desktop/scripts/diag-code-live.mjs b/apps/desktop/scripts/diag-code-live.mjs new file mode 100644 index 000000000000..98ec39973d9a --- /dev/null +++ b/apps/desktop/scripts/diag-code-live.mjs @@ -0,0 +1,25 @@ +// Is the tree-split preview path actually active in the running renderer? +// Checks the served source (what vite compiled) rather than guessing. +import { attach } from './perf/lib/launch.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +try { + await cdp.send('Runtime.enable') + + const out = await cdp.eval(`(async () => { + const res = await fetch('/src/components/pane-shell/tree/renderer/tree-split.tsx') + const src = await res.text() + return JSON.stringify({ + previewShift: src.includes('previewShift'), + adaptiveFloor: (await (await fetch('/src/app/session/hooks/use-message-stream/index.ts')).text()).includes('adaptiveFloor'), + structuralSignature: (await (await fetch('/src/components/assistant-ui/thread/list.tsx')).text()).includes('structuralSignature'), + sharedRO: (await (await fetch('/src/hooks/use-resize-observer.ts')).text()).includes('sharedObserver'), + rootTipProvider: (await (await fetch('/src/main.tsx')).text()).includes('RootTooltipProvider') + }) + })()`) + + console.log(out) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-key-latency.mjs b/apps/desktop/scripts/diag-key-latency.mjs new file mode 100644 index 000000000000..7a29bd46cad0 --- /dev/null +++ b/apps/desktop/scripts/diag-key-latency.mjs @@ -0,0 +1,47 @@ +// Typing latency, isolated: keystroke -> next paint, with and without an +// active stream. Distinguishes "input is slow" from "the frame budget is +// consumed by streaming flushes" — the fix differs completely. +import { attach } from './perf/lib/launch.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +const TYPE = ` + (async () => { + const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent) + if (!el) return JSON.stringify({ error: 'no composer' }) + el.focus() + const perKey = [] + for (let i = 0; i < 30; i++) { + const ch = 'abcdefghij'[i % 10] + const t0 = performance.now() + el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch })) + el.textContent += ch + el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' })) + el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch })) + await new Promise(r => requestAnimationFrame(r)) + perKey.push(performance.now() - t0) + // Human-ish 80ms cadence so streaming flushes interleave realistically. + await new Promise(r => setTimeout(r, 80)) + } + el.textContent = '' + el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' })) + const sorted = [...perKey].sort((a, b) => a - b) + const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] + const busy = (() => { try { return document.querySelectorAll('[data-status="running"]').length } catch { return -1 } })() + return JSON.stringify({ + keyToPaint_p50: Math.round(pct(0.5) * 10) / 10, + keyToPaint_p95: Math.round(pct(0.95) * 10) / 10, + worst: Math.round(sorted[sorted.length - 1] * 10) / 10, + over16: perKey.filter(f => f > 16.7).length, + over33: perKey.filter(f => f > 33).length, + streamingParts: busy + }) + })() +` + +try { + await cdp.send('Runtime.enable') + console.log(await cdp.eval(TYPE)) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-live-state.mjs b/apps/desktop/scripts/diag-live-state.mjs new file mode 100644 index 000000000000..7526bf0e65fb --- /dev/null +++ b/apps/desktop/scripts/diag-live-state.mjs @@ -0,0 +1,21 @@ +// Quick state probe of the running hgui instance via CDP. +import { attach } from './perf/lib/launch.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +try { + await cdp.send('Runtime.enable') + + const state = await cdp.eval(`(() => { + const rc = !!window.__RENDER_COUNTS__ + const pl = !!window.__PERF_LIVE__ + const tiles = window.__HERMES_SESSION_TILES__ ? Object.keys(window.__HERMES_SESSION_TILES__.states()).length : -1 + const gw = document.querySelector('[data-slot="statusbar"]')?.textContent?.slice(0, 120) ?? '(no statusbar)' + const sidebarRows = document.querySelectorAll('[data-slot="sidebar"] [data-session-id], [data-tree-group] a').length + return JSON.stringify({ rc, pl, tiles, gw, sidebarRows, title: document.title, url: location.href.slice(0, 80) }) + })()`) + + console.log(state) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-real-loop.mjs b/apps/desktop/scripts/diag-real-loop.mjs new file mode 100644 index 000000000000..e3bde3b38088 --- /dev/null +++ b/apps/desktop/scripts/diag-real-loop.mjs @@ -0,0 +1,149 @@ +// The real-app perf loop: drive HER hgui instance (real profile, real +// sessions) through the three interactions that matter — session switch, +// sidebar drag, composer typing — and report honest single-clock numbers. +// +// node scripts/diag-real-loop.mjs [--port 9222] [--switches 6] +// +// Unlike the synthetic scenarios this clicks REAL sidebar rows, so session +// switching is measured as the user feels it: click -> transcript painted. + +import { attach } from './perf/lib/launch.mjs' +import { sleep } from './perf/lib/cdp.mjs' + +const arg = (name, fallback) => { + const i = process.argv.indexOf(`--${name}`) + + return i === -1 ? fallback : process.argv[i + 1] +} + +const port = Number(arg('port', 9222)) +const SWITCHES = Number(arg('switches', 6)) + +const { cdp, teardown } = await attach({ port }) + +// --------------------------------------------------------------------------- +// Session switch: click a sidebar session row, await the transcript settling. +// Measures click -> first paint of the new transcript AND click -> settled +// (two rAFs with no further DOM mutation in the thread viewport). +// --------------------------------------------------------------------------- +const SWITCH = swaps => ` + (async () => { + const rows = [...document.querySelectorAll('[data-slot="row-button"]')] + .filter(el => el.offsetParent && (el.textContent ?? '').trim()) + if (rows.length < 2) return JSON.stringify({ error: 'need 2+ visible session rows, found ' + rows.length }) + + const results = [] + for (let i = 0; i < ${swaps}; i++) { + const row = rows[i % Math.min(rows.length, 4)] + const viewport = () => document.querySelector('[data-slot="aui_thread-viewport"]') + const t0 = performance.now() + row.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0, buttons: 1 })) + row.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0 })) + row.click() + + // First paint: next two rAFs after the click. + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))) + const firstPaint = performance.now() - t0 + + // Settled: no mutations in the viewport for 2 consecutive frames, capped 3s. + let lastMutation = performance.now() + const target = viewport() ?? document.body + const mo = new MutationObserver(() => { lastMutation = performance.now() }) + mo.observe(target, { childList: true, subtree: true, characterData: true }) + const deadline = performance.now() + 3000 + while (performance.now() < deadline) { + await new Promise(r => requestAnimationFrame(r)) + if (performance.now() - lastMutation > 120) break + } + mo.disconnect() + results.push({ firstPaint: Math.round(firstPaint), settled: Math.round(performance.now() - t0 - 120) }) + await new Promise(r => setTimeout(r, 250)) + } + return JSON.stringify(results) + })() +` + +// --------------------------------------------------------------------------- +// Drag the first visible sash, single-clock frames. +// --------------------------------------------------------------------------- +const DRAG = ` + (async () => { + const handle = [...document.querySelectorAll('[role="separator"]')].find(el => el.offsetParent || el.getBoundingClientRect().width > 0) + if (!handle) return JSON.stringify({ error: 'no sash' }) + const box = handle.getBoundingClientRect() + const y = box.top + box.height / 2 + const x0 = box.left + box.width / 2 + let x = x0 + const o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 } + const frames = [] + let last = performance.now() + handle.dispatchEvent(new PointerEvent('pointerdown', { ...o, clientX: x, clientY: y })) + for (let i = 0; i < 60; i++) { + x += (i < 30 ? 2 : -2) + window.dispatchEvent(new PointerEvent('pointermove', { ...o, clientX: x, clientY: y })) + await new Promise(r => requestAnimationFrame(r)) + const now = performance.now(); frames.push(now - last); last = now + } + window.dispatchEvent(new PointerEvent('pointerup', { ...o, buttons: 0, clientX: x, clientY: y })) + const total = frames.reduce((a, b) => a + b, 0) + const sorted = [...frames].sort((a, b) => a - b) + return JSON.stringify({ + fps: Math.round((frames.length / total) * 1000 * 10) / 10, + p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10, + worst: Math.round(sorted[sorted.length - 1] * 10) / 10, + slow33: frames.filter(f => f > 33).length + }) + })() +` + +// --------------------------------------------------------------------------- +// Type into the composer, single-clock frames (one mark per keystroke frame). +// --------------------------------------------------------------------------- +const TYPE = ` + (async () => { + const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent) + if (!el) return JSON.stringify({ error: 'no composer' }) + el.focus() + const frames = [] + let last = performance.now() + for (let i = 0; i < 40; i++) { + const ch = 'the quick brown fox '[i % 20] + el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch })) + el.textContent += ch + el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' })) + el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch })) + await new Promise(r => requestAnimationFrame(r)) + const now = performance.now(); frames.push(now - last); last = now + await new Promise(r => setTimeout(r, 20)) + } + // Clear what we typed. + el.textContent = '' + el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' })) + const moving = frames + const total = moving.reduce((a, b) => a + b, 0) + const sorted = [...moving].sort((a, b) => a - b) + return JSON.stringify({ + fps: Math.round((moving.length / total) * 1000 * 10) / 10, + p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10, + worst: Math.round(sorted[sorted.length - 1] * 10) / 10, + slow33: moving.filter(f => f > 33).length + }) + })() +` + +try { + await cdp.send('Runtime.enable') + + console.log('== SESSION SWITCH (click -> paint / settled ms) ==') + console.log(await cdp.eval(SWITCH(SWITCHES))) + + await sleep(500) + console.log('\n== SIDEBAR DRAG ==') + console.log(await cdp.eval(DRAG)) + + await sleep(500) + console.log('\n== COMPOSER TYPING ==') + console.log(await cdp.eval(TYPE)) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-sidebar-dom.mjs b/apps/desktop/scripts/diag-sidebar-dom.mjs new file mode 100644 index 000000000000..64159ca6d5e0 --- /dev/null +++ b/apps/desktop/scripts/diag-sidebar-dom.mjs @@ -0,0 +1,27 @@ +// Dump the sidebar's actual DOM shape so selectors stop being guesses. +import { attach } from './perf/lib/launch.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +try { + await cdp.send('Runtime.enable') + + const out = await cdp.eval(`(() => { + const sidebar = document.querySelector('[data-slot="sidebar"]') ?? document.querySelector('aside') + if (!sidebar) return '(no sidebar el)' + // Find clickable rows: anchors or buttons with text, depth-limited sample. + const clickables = [...sidebar.querySelectorAll('a, button, [role="button"], [data-slot]')].slice(0, 60) + const rows = clickables.map(el => ({ + tag: el.tagName.toLowerCase(), + slot: el.getAttribute('data-slot') ?? '', + cls: (el.className?.baseVal ?? el.className ?? '').toString().slice(0, 40), + text: (el.textContent ?? '').trim().slice(0, 30), + visible: !!el.offsetParent + })).filter(r => r.text) + return JSON.stringify(rows.slice(0, 30), null, 1) + })()`) + + console.log(out) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-switch-trace.mjs b/apps/desktop/scripts/diag-switch-trace.mjs new file mode 100644 index 000000000000..8efe5462e3fe --- /dev/null +++ b/apps/desktop/scripts/diag-switch-trace.mjs @@ -0,0 +1,74 @@ +// What happens during a SLOW session switch? Click a heavy row with tracing +// on, dump the style/layout/script split plus top callsites. +import { attach } from './perf/lib/launch.mjs' +import { sleep } from './perf/lib/cdp.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +const CLICK_HEAVIEST = ` + (() => { + const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent) + if (rows.length < 2) return 'need rows' + // Alternate between the first two rows so every run actually switches. + const current = location.hash + const target = rows.find(r => !r.getAttribute('data-active')) ?? rows[1] + target.click() + return 'clicked: ' + (target.textContent ?? '').slice(0, 40) + })() +` + +const events = [] +let complete = false +cdp.on('Tracing.dataCollected', p => events.push(...(p.value ?? []))) +cdp.on('Tracing.tracingComplete', () => { + complete = true +}) + +try { + await cdp.send('Runtime.enable') + + await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { includedCategories: ['devtools.timeline'] } + }) + + console.log(await cdp.eval(CLICK_HEAVIEST)) + await sleep(2500) + console.log(await cdp.eval(CLICK_HEAVIEST)) + await sleep(2500) + + await cdp.send('Tracing.end') + + for (let w = 0; !complete && w < 10000; w += 200) { + await sleep(200) + } + + const totals = new Map() + const byFn = new Map() + + for (const e of events) { + if (e.ph !== 'X' || typeof e.dur !== 'number') { + continue + } + + totals.set(e.name, (totals.get(e.name) ?? 0) + e.dur / 1000) + + if (e.name === 'FunctionCall') { + const d = e.args?.data ?? {} + const key = `${d.functionName || '(anon)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}` + byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000) + } + } + + const style = totals.get('UpdateLayoutTree') ?? 0 + const layout = totals.get('Layout') ?? 0 + const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0) + console.log(`\nVERDICT over 2 switches: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms paint=${(totals.get('Paint') ?? 0).toFixed(0)}ms`) + console.log('\nTOP CALLSITES:') + + for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)) { + console.log(` ${ms.toFixed(1).padStart(8)} ${name}`) + } +} finally { + teardown?.() +} diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 2346936fa94f..8a50465cdc21 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -29,7 +29,7 @@ import { setSessionTodos } from '@/store/todos' import type { ClientSessionState } from '../../../types' import { useGatewayEventHandler } from './gateway-event' -import { completionErrorText, delegateTaskPayloads, STREAM_DELTA_FLUSH_MS } from './utils' +import { completionErrorText, delegateTaskPayloads, MAX_STREAM_FLUSH_GAP_MS, STREAM_DELTA_FLUSH_MS } from './utils' interface MessageStreamOptions { activeGatewayProfile?: string @@ -184,6 +184,9 @@ export function useMessageStream({ const queuedDeltasRef = useRef<Map<string, QueuedStreamDeltas>>(new Map()) const flushHandleRef = useRef<number | null>(null) const lastFlushAtRef = useRef<number>(0) + // What the previous flush cost on the main thread — drives the adaptive + // flush floor in scheduleDeltaFlush so multi-stream load yields to input. + const lastFlushCostRef = useRef<number>(0) const nativeSubagentSessionsRef = useRef<Set<string>>(new Set()) // Turns that auto-compacted: skip post-turn hydrate so live scrollback survives. const compactedTurnRef = useRef<Set<string>>(new Set()) @@ -243,12 +246,28 @@ export function useMessageStream({ // length. With this floor, slower streams still coalesce ~2 tokens per // commit and the synthetic harness shows longtask counts drop from ~5/5s // to ~1/5s on big sessions (see scripts/profile-typing-lag.md). + // + // ADAPTIVE: the floor scales with what the last flush actually cost. + // With several sessions streaming at once (split tiles), one flush carries + // every stream's commit + markdown re-parse; when that work approaches or + // exceeds the fixed 33ms budget, back-to-back flushes leave the main + // thread no idle frames and every interaction (typing, resize, hover) + // stutters even though no render is wasted. Yielding 3x the measured cost + // keeps the thread ~75% idle for input at any load: cheap flushes stay at + // 30fps of text growth, expensive multi-stream flushes degrade text fps + // instead of interactivity — capped so text never updates slower than 4/s. const sinceLast = performance.now() - lastFlushAtRef.current + const adaptiveFloor = Math.min( + Math.max(STREAM_DELTA_FLUSH_MS, lastFlushCostRef.current * 3), + MAX_STREAM_FLUSH_GAP_MS + ) const runFlush = () => { flushHandleRef.current = null - lastFlushAtRef.current = performance.now() + const startedAt = performance.now() + lastFlushAtRef.current = startedAt flushQueuedDeltas() + lastFlushCostRef.current = performance.now() - startedAt } // Always a timer, never requestAnimationFrame. Chromium pauses rAF for a @@ -265,7 +284,7 @@ export function useMessageStream({ // for) while guaranteeing delivery without user interaction. Timers are // clamped in background renderers rather than suspended, and // disable-background-timer-throttling already opts out of that clamp. - flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast)) + flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, adaptiveFloor - sinceLast)) }, [flushQueuedDeltas]) const queueDelta = useCallback( diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts b/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts index d9a90b366920..0da74927639d 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts @@ -66,6 +66,12 @@ export function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boole // `scripts/profile-typing-lag.md` for the measurement work behind this. export const STREAM_DELTA_FLUSH_MS = 33 +// Ceiling for the ADAPTIVE flush gap (see scheduleDeltaFlush). Under heavy +// multi-stream load the gap stretches to 3x the measured flush cost so the +// main thread stays responsive to input; this cap guarantees streaming text +// still visibly updates at least ~4x per second no matter the load. +export const MAX_STREAM_FLUSH_GAP_MS = 250 + // Gateway/provider failures sometimes arrive as message.complete text instead // of an explicit error event. Treat matches as inline assistant errors so they // persist like real error events and don't get erased by hydrate fallback. diff --git a/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx b/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx index f0fafbc4f18f..4cae22b5c0c0 100644 --- a/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx +++ b/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx @@ -13,8 +13,13 @@ const isTransientLookupError = (error: unknown): boolean => error instanceof Error && /(useClientLookup|tapClient(Lookup|Resource)).*out of bounds/.test(error.message) interface Props { - // Changes whenever the message list mutates; remounting clears the caught - // error so the next consistent render recovers silently. + // Changes whenever the message list mutates STRUCTURALLY (ids/roles/count); + // remounting clears the caught error so the next consistent render recovers + // silently. Deliberately NOT the per-token signature: this prop reaches + // every turn's boundary, so a value that ticks with content length would + // re-render every boundary — and reconcile every turn's subtree — on every + // streamed token (measured: 540 wasted Block renders per explain() sample + // with two threads streaming). resetKey: string children: ReactNode } diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index e80938cf4d0e..676a0f57136c 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -9,6 +9,7 @@ import { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, useState } from 'react' @@ -141,14 +142,25 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({ loadingIndicator, sessionKey }) => { - const messageSignature = useAuiState(s => - s.thread.messages - .map((message, index) => `${index}:${message.id}:${message.role}:${message.content?.length ?? 1}`) - .join('\n') + // TWO signatures, deliberately split. The STRUCTURAL one (ids/roles/count) + // changes only when messages are added/removed/swapped — it keys the error + // boundaries and the row identity. The WEIGHT one (per-message part counts) + // ticks while a streaming turn appends parts — it feeds only the render + // budget. Folding weights into the structural key handed every boundary a + // new resetKey per appended part, which reconciled every turn's subtree on + // every tick (measured: 540 wasted Block renders per explain() sample with + // two threads streaming). + const structuralSignature = useAuiState(s => + s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n') ) + const weightSignature = useAuiState(s => s.thread.messages.map(message => message.content?.length ?? 1).join(',')) + const { t } = useI18n() - const groups = buildGroups(messageSignature) + // Row structure is memoized on the STRUCTURAL signature only, so streaming + // part-appends can't churn group identity (that would defeat the rows memo + // below on every tick). Weights are folded in separately for the budget. + const groups = useMemo(() => buildGroups(structuralSignature), [structuralSignature]) const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder) // use-stick-to-bottom owns scrollTop (single writer): follow while locked, @@ -213,7 +225,22 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({ return () => cancelAnimationFrame(rafId) }, [renderBudget]) - const hiddenCount = firstVisibleGroupIndex(groups, renderBudget) + // Weights (per-message part counts) fold into the BUDGET only. Group + // identity stays structural, so a streaming append re-runs this cheap sum — + // not the row JSX. Weighted the same way the old combined signature was. + const weightedGroups = useMemo(() => { + const weights = weightSignature.split(',').map(w => Number(w) || 1) + + return groups.map(group => ({ + ...group, + weight: + group.kind === 'turn' + ? group.indices.reduce((sum, index) => sum + (weights[index] ?? 1), 0) + : (weights[group.index] ?? 1) + })) + }, [groups, weightSignature]) + + const hiddenCount = firstVisibleGroupIndex(weightedGroups, renderBudget) const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups const restoreFromBottomRef = useRef<number | null>(null) // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out) @@ -331,6 +358,59 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({ } }, [scrollRef, renderBudget]) + // The row array is memoized on the inputs the rows actually read. This + // component re-renders on every isAtBottom flip — and use-stick-to-bottom + // flips it from a ResizeObserver, so a sidebar DRAG re-renders this list per + // frame. Without the memo, the inline .map() rebuilt every row's JSX each + // time, and rebuilt children re-render their whole subtree even when nothing + // changed (measured live: 865 wasted Block renders in one drag, walked to + // "MessageRenderBoundary (children only)" by explain()). With it, React + // bails out on element identity and a scroll flip re-renders nothing below. + const rows = useMemo( + () => + visibleGroups.map((group, indexInVisible) => ( + // content-visibility:auto — off-screen turns skip style recalc, + // layout, and paint. On a long transcript this is what keeps + // UNRELATED UI fast: any dialog/popover mount (Radix Presence + // reads getComputedStyle) forces a whole-document style recalc, + // measured ~650-730ms per open on a 1300-message session and + // ~100-200ms with this on. contain-intrinsic-size keeps a + // placeholder height for never-rendered turns (auto: remembered + // real size once rendered), so scrollbar/anchoring stay stable. + // Sticky human bubbles are unaffected — their turn is rendered + // whenever any part of it intersects the viewport. + // + // The live tail (newest turns) is exempt: virtualizing a turn + // whose final size hasn't been remembered yet snaps it to a stale + // height when it scrolls off, drifting stick-to-bottom up over old + // turns. See isVirtualizedGroup. + <div + className={cn( + 'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)', + isVirtualizedGroup(indexInVisible, visibleGroups.length) && + '[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]' + )} + key={group.id} + > + <MessageRenderBoundary resetKey={structuralSignature}> + {group.kind === 'turn' ? ( + <div + className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)" + data-slot="aui_turn-pair" + > + {group.indices.map(index => ( + <ThreadPrimitive.MessageByIndex components={components} index={index} key={index} /> + ))} + </div> + ) : ( + <ThreadPrimitive.MessageByIndex components={components} index={group.index} /> + )} + </MessageRenderBoundary> + </div> + )), + [visibleGroups, components, structuralSignature] + ) + return ( <div className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]" @@ -380,46 +460,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({ {t.assistant.thread.showEarlier} </button> )} - {visibleGroups.map((group, indexInVisible) => ( - // content-visibility:auto — off-screen turns skip style recalc, - // layout, and paint. On a long transcript this is what keeps - // UNRELATED UI fast: any dialog/popover mount (Radix Presence - // reads getComputedStyle) forces a whole-document style recalc, - // measured ~650-730ms per open on a 1300-message session and - // ~100-200ms with this on. contain-intrinsic-size keeps a - // placeholder height for never-rendered turns (auto: remembered - // real size once rendered), so scrollbar/anchoring stay stable. - // Sticky human bubbles are unaffected — their turn is rendered - // whenever any part of it intersects the viewport. - // - // The live tail (newest turns) is exempt: virtualizing a turn - // whose final size hasn't been remembered yet snaps it to a stale - // height when it scrolls off, drifting stick-to-bottom up over old - // turns. See isVirtualizedGroup. - <div - className={cn( - 'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)', - isVirtualizedGroup(indexInVisible, visibleGroups.length) && - '[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]' - )} - key={group.id} - > - <MessageRenderBoundary resetKey={messageSignature}> - {group.kind === 'turn' ? ( - <div - className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)" - data-slot="aui_turn-pair" - > - {group.indices.map(index => ( - <ThreadPrimitive.MessageByIndex components={components} index={index} key={index} /> - ))} - </div> - ) : ( - <ThreadPrimitive.MessageByIndex components={components} index={group.index} /> - )} - </MessageRenderBoundary> - </div> - ))} + {rows} {loadingIndicator} {clampToComposer && ( <div diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index ac0b42423e9f..faceea5b2333 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -87,8 +87,20 @@ const ThinkingDisclosure: FC<{ return } - const pin = () => { - el.scrollTop = el.scrollHeight + // Height-gated: the observer also fires when the container's WIDTH changes + // (sidebar sash drag resizes every message), and pinning there forces a + // scrollHeight read+write per preview per frame. Only actual content + // growth needs the pin; the height rides the RO entry, reflow-free. + let lastHeight = -1 + + const pin = (entries: readonly ResizeObserverEntry[]) => { + const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1 + const grew = height < 0 || height > lastHeight + lastHeight = height + + if (grew) { + el.scrollTop = el.scrollHeight + } } // No sync pin(): the observer's guaranteed initial delivery runs it with diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 7c004e07c1fa..746a64dccdfe 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -755,7 +755,25 @@ function useToolWindow(enabled: boolean) { return } - const pin = () => { + // Track the content's HEIGHT and only pin when it grows. The observer also + // fires for width changes — a sidebar sash drag resizes every tool window + // once per frame — and pinning there is (a) pointless, the list didn't + // grow, and (b) expensive: `pin` writes scrollTop then `syncFade` reads it + // back, a write->read forced reflow per tool group per frame. Measured on + // a real session while dragging the sash: 927ms of `pin` script plus + // 2.7s of style recalc across one 60-frame drag. Reading the height off + // the RO entry keeps the check reflow-free. + let lastHeight = -1 + + const pin = (entries: readonly ResizeObserverEntry[]) => { + const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1 + const grew = height < 0 || height > lastHeight + lastHeight = height + + if (!grew) { + return + } + if (stickRef.current) { el.scrollTop = el.scrollHeight } diff --git a/apps/desktop/src/components/pane-shell/geometry.ts b/apps/desktop/src/components/pane-shell/geometry.ts index a2a484188b42..31eecd15341f 100644 --- a/apps/desktop/src/components/pane-shell/geometry.ts +++ b/apps/desktop/src/components/pane-shell/geometry.ts @@ -158,6 +158,31 @@ function sameRect(a: Rect | null, b: Rect | null) { // Workspace-edge CSS vars // --------------------------------------------------------------------------- +// --- Sash-drag deferral ------------------------------------------------------ +// The tree sash sets this for the duration of a resize gesture (pointerdown to +// pointerup). While set, `publishWorkspaceGeometry` skips its :root custom +// property writes: each one invalidates computed style for the whole document, +// and the sash's ResizeObserver fires per frame. Measured live (LoAF, real +// session): drag frames of ~68ms with style+layout=67ms and no script ≥5ms; +// suppressing the writes recovered 14fps → 51fps. The vars only align titlebar +// chrome — republishing once on release is visually identical. +let sashDragDepth = 0 +let onSashDragEnd: null | (() => void) = null + +export function beginSashDrag() { + sashDragDepth += 1 +} + +export function endSashDrag() { + sashDragDepth = Math.max(0, sashDragDepth - 1) + + if (sashDragDepth === 0) { + onSashDragEnd?.() + } +} + +const sashDragging = () => sashDragDepth > 0 + /** * Publish the workspace zone's viewport edges as root CSS vars: * --workspace-left : px from the viewport's left to the main zone @@ -177,6 +202,12 @@ export function publishWorkspaceGeometry(): () => void { const ro = new ResizeObserver(() => measure()) const measure = () => { + // DEFER during a sash drag (see beginSashDrag above) — republished once on + // release via the onSashDragEnd hook registered below. + if (sashDragging()) { + return + } + const next = document.querySelector<HTMLElement>('[data-session-anchor="workspace"]') if (next !== el) { @@ -216,11 +247,14 @@ export function publishWorkspaceGeometry(): () => void { // frame later, after the DOM committed. RO covers width changes (sash drags, // side collapses); window resize covers the rest. const unsubTree = $layoutTree.listen(() => requestAnimationFrame(measure)) + // Drag released → publish the final geometry the deferral above skipped. + onSashDragEnd = () => requestAnimationFrame(measure) window.addEventListener('resize', measure) measure() return () => { unsubTree() + onSashDragEnd = null window.removeEventListener('resize', measure) ro.disconnect() root.style.removeProperty('--workspace-left') diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx index d963d79efb8c..63f61f4ad342 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx @@ -9,6 +9,7 @@ import { useStore } from '@nanostores/react' import { type PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, useSyncExternalStore } from 'react' +import { beginSashDrag, endSashDrag } from '@/components/pane-shell/geometry' import { useContributions } from '@/contrib/react/use-contributions' import { rafCoalesce } from '@/lib/raf-coalesce' import { cn } from '@/lib/utils' @@ -234,9 +235,33 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize' document.body.style.userSelect = 'none' + // Suppress :root geometry-var writes for the gesture (see geometry.ts — + // each one restyles the whole document; they republish on release). + beginSashDrag() // pointermove outpaces 60fps and each write relayouts the whole pane tree, // so coalesce to one apply per frame (rafCoalesce commits on cleanup). + // + // During the gesture the store is NOT written. setTreeSplitWeights / + // setPaneWidthOverride each mint a new tree/pane-state object, and the + // resulting commit walks every mounted pane — measured live on a real + // 2-session layout: 31 commits across a 58-frame drag, 20.7fps, with + // TreeNode at 490ms and Block/Ct re-parsing markdown for 620ms. The + // store is written ONCE on release; during the drag the seam is + // previewed with inline styles on the same wrappers React sizes. + // + // Preview rules (learned the hard way — a wrong shape here left a + // phantom gap where a hidden sidebar lived): + // - a FIXED side gets ONLY a flex-basis override. Its wrapper renders + // as `flex: 0 1 <track>`, so basis is the whole difference; grow and + // shrink stay React's. Crucially the flex partner is left untouched, + // so it keeps absorbing the remainder and no leftover gap can open. + // - a flex-vs-flex seam pins both sides to `0 1 <px>`. Their combined + // px is constant, so sibling flex tracks see the same leftover. + // - cleanup: a real drag commits the store once, and React's re-render + // rewrites the `flex` shorthand, which clears the overrides (writing + // the shorthand resets the longhands). A no-movement click restores + // the captured style attribute instead, since nothing re-renders. const applyShift = (shiftPx: number) => { if (a.fixed) { a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx))) @@ -255,14 +280,58 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo } } - const resize = rafCoalesce(applyShift) + const styleA = kidA.getAttribute('style') + const styleB = kidB.getAttribute('style') + + const previewSide = (el: HTMLElement, fixed: boolean, px: number) => { + if (fixed) { + el.style.flexBasis = `${px}px` + } else if (!a.fixed && !b.fixed) { + el.style.flex = `0 1 ${px}px` + } + // Mixed seam, flex side: untouched — it absorbs what the fixed side + // gives up, exactly as the track model would render it. + } + + const previewShift = (shiftPx: number) => { + previewSide(kidA, a.fixed, a0px + shiftPx) + previewSide(kidB, b.fixed, b0px - shiftPx) + } + + const resize = rafCoalesce(previewShift) + let lastShift: null | number = null const onMove = (ev: PointerEvent) => { - resize.push(Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start))) + lastShift = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start)) + resize.push(lastShift) } const cleanup = () => { resize.finish() + + if (lastShift !== null) { + // One store commit; the re-render rewrites `flex` and clears the + // preview overrides. + applyShift(lastShift) + } else { + // Click without movement: nothing will re-render, so put the + // wrappers' inline styles back exactly as React last wrote them. + if (styleA === null) { + kidA.removeAttribute('style') + } else { + kidA.setAttribute('style', styleA) + } + + if (styleB === null) { + kidB.removeAttribute('style') + } else { + kidB.setAttribute('style', styleB) + } + } + + // Geometry vars re-enable AFTER the final store commit above, so the + // release publishes exactly one fresh measurement. + endSashDrag() document.body.style.cursor = restoreCursor document.body.style.userSelect = restoreSelect diff --git a/apps/desktop/src/debug/index.ts b/apps/desktop/src/debug/index.ts index 365cc23fdbfa..e5eb37b55b21 100644 --- a/apps/desktop/src/debug/index.ts +++ b/apps/desktop/src/debug/index.ts @@ -25,6 +25,10 @@ // with no changed input, and stores that published a value equal to the last. import './render-counter' +// Live interaction profiler — arms on real resize/typing so we can measure the +// app under REAL sessions instead of a synthetic scenario's toy transcripts. +// window.__PERF_LIVE__.on() in the console, then just use the app. +import './perf-live' import { watchSessionAtoms } from './watched-atoms' diff --git a/apps/desktop/src/debug/perf-live.ts b/apps/desktop/src/debug/perf-live.ts new file mode 100644 index 000000000000..ce73b7738c99 --- /dev/null +++ b/apps/desktop/src/debug/perf-live.ts @@ -0,0 +1,306 @@ +// Live interaction profiler — for driving the app by hand and seeing what the +// app actually does under YOUR sessions, not a synthetic scenario's. +// +// The synthetic scenarios seed toy transcripts (short prose, no tool calls, no +// code blocks). A real session is heavier in ways that matter, so a scenario +// can report 57fps on a gesture that visibly lags in the real app. This closes +// that gap by measuring the real thing. +// +// It arms itself on any pointerdown on a resize handle and on composer typing, +// records frames + render attribution for the duration of the interaction, and +// prints a table when the interaction ends. Idle cost is zero: nothing is +// observed until an interaction starts. +// +// window.__PERF_LIVE__.on() start watching (also: ?perflive=1) +// window.__PERF_LIVE__.off() +// window.__PERF_LIVE__.last() the most recent report as an object +// +// Dev-only; the whole debug/ graph is aliased out of production builds. + +interface Sample { + kind: string + ms: number + frames: number + fps: number + p95: number + worst: number + slow33: number + commits: number + top: Array<{ name: string; renders: number; wasted: number; totalMs: number }> + longFrames: LongFrame[] +} + +/** One Long Animation Frame, attributed. `styleMs` is the engine's style+layout + * time inside the frame; `scripts` names who ran JS and for how long. This is + * the half the render counter cannot see — a frame can cost 900ms with almost + * no React in it, and only LoAF says whether that was layout, a ResizeObserver + * callback loop, or some timer. */ +interface LongFrame { + ms: number + styleMs: number + blockingMs: number + scripts: Array<{ invoker: string; ms: number; src: string }> +} + +const RESIZE_SELECTOR = '[role="separator"], [data-slot="pane-resize-handle"], [class*="cursor-col-resize"], [class*="cursor-row-resize"]' +const TYPING_SELECTOR = '[contenteditable="true"], textarea, input[type="text"]' + +// A gesture is "over" once this long passes with no further input events. +const IDLE_END_MS = 350 +// Don't report trivial blips (a click, a single keypress). +const MIN_FRAMES = 6 + +let watching = false + +let active: null | { + kind: string + startedAt: number + frames: number[] + last: number + raf: number + endTimer: ReturnType<typeof setTimeout> | null +} = null + +let lastReport: null | Sample = null + +// Long Animation Frames observed while a gesture is active. LoAF entries name +// the scripts inside each long frame and split out style/layout time — the +// half of a frame the render counter cannot see. +let longFrames: LongFrame[] = [] + +const loafObserver = + typeof PerformanceObserver !== 'undefined' && PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame') + ? new PerformanceObserver(list => { + if (!active) { + return + } + + for (const entry of list.getEntries()) { + const e = entry as PerformanceEntry & { + blockingDuration?: number + styleAndLayoutStart?: number + renderStart?: number + scripts?: Array<{ + duration: number + invoker?: string + invokerType?: string + sourceURL?: string + sourceFunctionName?: string + }> + } + + longFrames.push({ + blockingMs: Math.round(e.blockingDuration ?? 0), + ms: Math.round(e.duration), + scripts: (e.scripts ?? []) + .filter(s => s.duration >= 5) + .map(s => ({ + invoker: `${s.invokerType ?? ''}:${s.invoker ?? s.sourceFunctionName ?? '?'}`, + ms: Math.round(s.duration), + src: (s.sourceURL ?? '').split('/').pop() ?? '' + })), + // styleAndLayoutStart -> frame end is the engine's style+layout tail. + styleMs: e.styleAndLayoutStart + ? Math.round(e.startTime + e.duration - e.styleAndLayoutStart) + : 0 + }) + } + }) + : null + +const pct = (sorted: number[], p: number) => + sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0 + +function finish() { + if (!active) { + return + } + + const { kind, startedAt, frames, raf } = active + active = null + cancelAnimationFrame(raf) + loafObserver?.disconnect() + const capturedLongFrames = longFrames + longFrames = [] + + const counter = window.__RENDER_COUNTS__ + const commits = counter?.commits() ?? 0 + + const top = (counter?.report(12) ?? []).map(r => ({ + name: r.name, + renders: r.renders, + wasted: r.wasted, + totalMs: r.totalMs + })) + + counter?.stop() + + if (frames.length < MIN_FRAMES) { + return + } + + const total = frames.reduce((a, b) => a + b, 0) + const sorted = [...frames].sort((a, b) => a - b) + + const report: Sample = { + commits, + fps: Math.round((frames.length / total) * 1000 * 10) / 10, + frames: frames.length, + kind, + longFrames: capturedLongFrames, + ms: Math.round(performance.now() - startedAt), + p95: Math.round(pct(sorted, 0.95) * 10) / 10, + slow33: frames.filter(f => f > 33).length, + top, + worst: Math.round(sorted[sorted.length - 1] * 10) / 10 + } + + lastReport = report + + const headline = + `%c${kind}%c ${report.fps}fps · ${report.frames} frames in ${report.ms}ms · ` + + `p95 ${report.p95}ms worst ${report.worst}ms · ${report.slow33} slow · ${commits} commits` + + console.log( + headline, + `background:${report.fps < 45 ? '#c0392b' : '#27ae60'};color:#fff;padding:1px 6px;border-radius:3px`, + 'color:inherit' + ) + + if (top.length) { + console.table(top) + } + + // The frame-engine side: what each long frame actually spent its time on. + for (const lf of capturedLongFrames.slice(0, 8)) { + const scripts = lf.scripts.map(s => `${s.invoker}@${s.src} ${s.ms}ms`).join(' | ') || '(no script ≥5ms)' + console.log(` ⏱ longframe ${lf.ms}ms style+layout ${lf.styleMs}ms block ${lf.blockingMs}ms → ${scripts}`) + } +} + +function begin(kind: string) { + if (active) { + // Same gesture continuing — just push the end deadline out. + if (active.endTimer) { + clearTimeout(active.endTimer) + } + + active.endTimer = setTimeout(finish, IDLE_END_MS) + + return + } + + window.__RENDER_COUNTS__?.start() + + try { + // Buffered so a long frame already in flight when the gesture starts is + // still attributed to it. + loafObserver?.observe({ buffered: true, type: 'long-animation-frame' }) + } catch { + // Older runtime without LoAF — headline still works, attribution is empty. + } + + const now = performance.now() + + const state = { + endTimer: setTimeout(finish, IDLE_END_MS) as ReturnType<typeof setTimeout> | null, + frames: [] as number[], + kind, + last: now, + raf: 0, + startedAt: now + } + + const tick = () => { + if (active !== state) { + return + } + + const t = performance.now() + state.frames.push(t - state.last) + state.last = t + state.raf = requestAnimationFrame(tick) + } + + active = state + state.raf = requestAnimationFrame(tick) +} + +function onPointerDown(event: PointerEvent) { + const target = event.target as Element | null + + if (target?.closest?.(RESIZE_SELECTOR)) { + begin('resize') + } +} + +function onPointerMove() { + if (active?.kind === 'resize') { + begin('resize') + } +} + +function onKeyDown(event: KeyboardEvent) { + const target = event.target as Element | null + + // Ignore pure modifiers and navigation — we want real text entry. + if (event.key.length !== 1 && event.key !== 'Backspace') { + return + } + + if (target?.closest?.(TYPING_SELECTOR)) { + begin('typing') + } +} + +function on() { + if (watching) { + return 'already watching' + } + + watching = true + window.addEventListener('pointerdown', onPointerDown, true) + window.addEventListener('pointermove', onPointerMove, true) + window.addEventListener('keydown', onKeyDown, true) + + + console.log( + '%cperf-live%c armed — resize a pane or type in the composer; a report prints when you stop.', + 'background:#5a7db0;color:#fff;padding:1px 6px;border-radius:3px', + 'color:inherit' + ) + + return 'watching' +} + +function off() { + watching = false + window.removeEventListener('pointerdown', onPointerDown, true) + window.removeEventListener('pointermove', onPointerMove, true) + window.removeEventListener('keydown', onKeyDown, true) + finish() + + return 'stopped' +} + +declare global { + interface Window { + __PERF_LIVE__?: { + on: () => string + off: () => string + last: () => null | Sample + watching: () => boolean + } + } +} + +if (typeof window !== 'undefined' && !window.__PERF_LIVE__) { + window.__PERF_LIVE__ = { last: () => lastReport, off, on, watching: () => watching } + + // Opt in for a whole session with ?perflive=1 so a reload keeps measuring. + if (new URLSearchParams(window.location.search).get('perflive') === '1') { + on() + } +} + +export {} diff --git a/apps/desktop/src/debug/render-counter.ts b/apps/desktop/src/debug/render-counter.ts index 2a41d792a245..1bb7e6cf8b7b 100644 --- a/apps/desktop/src/debug/render-counter.ts +++ b/apps/desktop/src/debug/render-counter.ts @@ -46,6 +46,12 @@ const counts = new Map<string, RenderRecord>() let commits = 0 let recording = false +// explain() state: while set, every wasted render of this component walks up +// the fiber tree to find the ancestor whose props/state/context actually +// changed — the origin of the cascade. +let explainTarget: null | string = null +const explainCauses = new Map<string, number>() + const blank = (): RenderRecord => ({ contextChanged: 0, propsChanged: 0, @@ -76,19 +82,51 @@ function propsChanged(fiber: Fiber): boolean { /** Did any hook's memoizedState change? Covers useState, useSyncExternalStore * (so nanostores `useStore`), useMemo, and useReducer alike. */ function stateChanged(fiber: Fiber): boolean { + return changedHookIndices(fiber).length > 0 +} + +/** Indices (source order) of the hooks whose memoizedState changed. The index + * maps straight onto the component's hook call order, so "hook #3 changed" + * identifies the exact useStore/useState line without guessing. */ +function changedHookIndices(fiber: Fiber): number[] { let next: Fiber['memoizedState'] | null | undefined = fiber.memoizedState let prev: Fiber['memoizedState'] | null | undefined = fiber.alternate?.memoizedState + const changed: number[] = [] + let index = 0 while (next && prev) { if (!Object.is(next.memoizedState, prev.memoizedState)) { - return true + changed.push(index) } next = next.next prev = prev.next + index += 1 } - return false + return changed +} + +/** Names of the props whose identity changed — the cascade origin's smoking + * gun. Used by explain() so the answer is "Streamdown (props: children)" and + * not just "Streamdown (props)". */ +function changedPropKeys(fiber: Fiber): string[] { + const prev = fiber.alternate?.memoizedProps as Record<string, unknown> | null | undefined + const next = fiber.memoizedProps as Record<string, unknown> | null | undefined + + if (!prev || !next) { + return [] + } + + const keys: string[] = [] + + for (const key of Object.keys(next)) { + if (!Object.is(prev[key], next[key])) { + keys.push(key) + } + } + + return keys } /** Did any consumed context value change? A `memo()` cannot block a re-render @@ -139,6 +177,46 @@ function record(fiber: Fiber) { if (!props && !state && !context) { entry.wasted += 1 + + // explain() support: walk UP from a wasted render to the TOP of the + // cascade — the highest ancestor that also rendered this commit. That + // fiber is the origin; its own changed props/state is the reason. + // Stopping at the first ancestor with changed props is wrong: JSX rebuilt + // by a parent makes every intermediate node report "children changed", + // which is the symptom cascading down, not the cause. + if (explainTarget && name === explainTarget) { + let origin: Fiber = fiber + let cursor = fiber.return + let hops = 0 + + while (cursor && hops < 80) { + if (isCompositeFiber(cursor) && didFiberRender(cursor)) { + origin = cursor + } + + cursor = cursor.return + hops += 1 + } + + const originName = getDisplayName(origin) ?? '?' + const changed = changedPropKeys(origin).filter(k => k !== 'children') + + const why = + origin === fiber + ? 'self' + : stateChanged(origin) + ? `state (hooks #${changedHookIndices(origin).slice(0, 5).join(',#')})` + : changed.length + ? `props: ${changed.slice(0, 4).join(',')}` + : contextChanged(origin) + ? 'context' + : changedPropKeys(origin).length + ? 'children only' + : 'no visible change (external store?)' + + const key = `${originName} (${why})` + explainCauses.set(key, (explainCauses.get(key) ?? 0) + 1) + } } counts.set(name, entry) @@ -168,6 +246,12 @@ declare global { report: (limit?: number) => Array<RenderRecord & { name: string }> /** Attribution for one component by display name. */ get: (name: string) => RenderRecord | undefined + /** + * Name a component (its displayName, e.g. 'Block'), interact, then call + * with no argument to get the tally of which CHANGED ancestor each of + * its wasted renders cascaded from. The origin, walked — not guessed. + */ + explain: (name?: null | string) => Record<string, number> | string } } } @@ -195,6 +279,20 @@ if (typeof window !== 'undefined' && !window.__RENDER_COUNTS__) { }, commits: () => commits, counts, + explain: name => { + if (name !== undefined) { + explainTarget = name + explainCauses.clear() + + if (name && !recording) { + recording = true + } + + return name ? `explaining ${name} — interact, then call explain() to read` : 'explain off' + } + + return Object.fromEntries([...explainCauses.entries()].sort((x, y) => y[1] - x[1])) + }, get: name => counts.get(name), recording: () => recording, report, From 81779fa6a0382a4b0e54742065e218979f6604fe Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Mon, 27 Jul 2026 01:14:29 -0500 Subject: [PATCH 41/71] perf(desktop): don't backfill the transcript while its thread streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching to a STREAMING session took ~1.4s to settle while an idle session settled in ~50ms. The autopsy probe named it: the FIRST_PAINT_BUDGET -> RENDER_BUDGET backfill runs as a transition, an interrupted transition restarts from scratch, and stream flushes land every 33-250ms — so the 300-part backfill re-rendered over and over (measured: 1374ms settle, 30 commits, Primitive.div x2237 for one switch). Gate the backfill on the thread being idle. The user lands on the live tail immediately either way; older turns backfill the moment the run ends, and 'Show earlier' remains the manual path meanwhile. Measured on the live app (diag-switch-autopsy, real sessions): switch to idle session ~35-55ms settled (unchanged) switch to streaming session 1374ms -> backfill deferred; lands at the live tail like any other switch Adds diag-switch-autopsy.mjs (per-switch settle/commits/top-renders) and live-drive.mjs (status/fps/drag one-liners against the running app). --- apps/desktop/scripts/diag-switch-autopsy.mjs | 52 ++++ apps/desktop/scripts/live-drive.mjs | 290 ++++++++++++++++++ .../components/assistant-ui/thread/list.tsx | 23 +- 3 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/scripts/diag-switch-autopsy.mjs create mode 100644 apps/desktop/scripts/live-drive.mjs diff --git a/apps/desktop/scripts/diag-switch-autopsy.mjs b/apps/desktop/scripts/diag-switch-autopsy.mjs new file mode 100644 index 000000000000..f90e0d529824 --- /dev/null +++ b/apps/desktop/scripts/diag-switch-autopsy.mjs @@ -0,0 +1,52 @@ +// Session-switch autopsy: click between the two heaviest rows repeatedly, +// recording per-switch (a) settled ms, (b) React commits, (c) top rendered +// components — so slow switches name themselves. +import { attach } from './perf/lib/launch.mjs' +import { sleep } from './perf/lib/cdp.mjs' + +const arg = (name, fallback) => { + const i = process.argv.indexOf(`--${name}`) + + return i === -1 ? fallback : process.argv[i + 1] +} + +const port = Number(arg('port', 9222)) +const ROUNDS = Number(arg('rounds', 8)) + +const { cdp, teardown } = await attach({ port }) + +const SWITCH_ONE = index => ` + (async () => { + const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent) + if (rows.length < 2) return JSON.stringify({ error: 'rows' }) + const row = rows[${index} % 2] + const rc = window.__RENDER_COUNTS__ + rc.start() + const t0 = performance.now() + row.click() + let lastMutation = performance.now() + const mo = new MutationObserver(() => { lastMutation = performance.now() }) + mo.observe(document.body, { childList: true, subtree: true, characterData: true }) + const deadline = performance.now() + 4000 + while (performance.now() < deadline) { + await new Promise(r => requestAnimationFrame(r)) + if (performance.now() - lastMutation > 150) break + } + mo.disconnect() + rc.stop() + const settled = Math.round(performance.now() - t0 - 150) + const report = rc.report(6).map(r => r.name + ':' + r.renders + '(' + Math.round(r.totalMs) + 'ms)') + return JSON.stringify({ label: (row.textContent ?? '').slice(0, 24), settled, commits: rc.commits(), top: report }) + })() +` + +try { + await cdp.send('Runtime.enable') + + for (let i = 0; i < ROUNDS; i++) { + console.log(await cdp.eval(SWITCH_ONE(i))) + await sleep(400) + } +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/live-drive.mjs b/apps/desktop/scripts/live-drive.mjs new file mode 100644 index 000000000000..a20d09f30cdd --- /dev/null +++ b/apps/desktop/scripts/live-drive.mjs @@ -0,0 +1,290 @@ +// Live-drive harness for the REAL hgui instance on :9222. +// +// node scripts/live-drive.mjs status — targets, session count, perf-live armed? +// node scripts/live-drive.mjs fps [seconds] — raw rAF fps over N seconds (default 4) +// node scripts/live-drive.mjs drag — drag the sidebar sash, report fps + LoAF +// node scripts/live-drive.mjs type — type into composer, report fps + LoAF +// node scripts/live-drive.mjs switch — cycle through sidebar sessions, per-switch ms +// node scripts/live-drive.mjs send "msg" — submit a prompt in the focused session +// node scripts/live-drive.mjs eval "expr" — arbitrary page eval +// +// Attaches to the page target directly (no perf-harness deps) so it works on +// the app Brooklyn actually runs, with her profile, her sessions, her layout. + +import { WebSocket } from 'ws' + +const PORT = Number(process.env.CDP_PORT ?? 9222) + +async function attach() { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json() + const page = list.find(t => t.type === 'page' && !/devtools/.test(t.url)) + + if (!page) { + throw new Error('no page target on :' + PORT) + } + + const ws = new WebSocket(page.webSocketDebuggerUrl, { maxPayload: 256 * 1024 * 1024 }) + await new Promise((resolve, reject) => { + ws.once('open', resolve) + ws.once('error', reject) + }) + + let id = 0 + const pending = new Map() + ws.on('message', raw => { + const msg = JSON.parse(raw) + + if (msg.id && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id) + pending.delete(msg.id) + msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result) + } + }) + + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const mid = ++id + pending.set(mid, { resolve, reject }) + ws.send(JSON.stringify({ id: mid, method, params })) + }) + + await send('Runtime.enable') + + const evaluate = async expression => { + const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }) + + if (r.exceptionDetails) { + throw new Error(r.exceptionDetails.exception?.description ?? 'eval failed') + } + + return r.result?.value + } + + return { evaluate, close: () => ws.close(), send } +} + +const FPS = seconds => ` + (async () => { + const frames = [] + let last = performance.now() + const end = last + ${seconds * 1000} + while (performance.now() < end) { + await new Promise(r => requestAnimationFrame(r)) + const now = performance.now() + frames.push(now - last) + last = now + } + const total = frames.reduce((a, b) => a + b, 0) + const sorted = [...frames].sort((a, b) => a - b) + const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] + return { + fps: Math.round((frames.length / total) * 1000 * 10) / 10, + p95: Math.round(pct(0.95) * 10) / 10, + worst: Math.round(sorted[sorted.length - 1] * 10) / 10, + slow33: frames.filter(f => f > 33).length, + n: frames.length + } + })() +` + +// LoAF recorder for a window of work driven inside `body`. +const WITH_LOAF = body => ` + (async () => { + const lofs = [] + const po = new PerformanceObserver(list => { + for (const e of list.getEntries()) { + lofs.push({ + ms: Math.round(e.duration), + block: Math.round(e.blockingDuration ?? 0), + style: e.styleAndLayoutStart ? Math.round(e.startTime + e.duration - e.styleAndLayoutStart) : 0, + scripts: (e.scripts ?? []).filter(s => s.duration >= 5).map(s => + (s.invokerType ?? '') + ':' + (s.invoker ?? s.sourceFunctionName ?? '?') + '@' + + ((s.sourceURL ?? '').split('/').pop() ?? '') + ' ' + Math.round(s.duration) + 'ms') + }) + } + }) + po.observe({ type: 'long-animation-frame', buffered: false }) + const frames = [] + let last = performance.now() + let stop = false + const tick = () => { + if (stop) return + const now = performance.now() + frames.push(now - last) + last = now + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + ${body} + stop = true + po.disconnect() + const total = frames.reduce((a, b) => a + b, 0) + const sorted = [...frames].sort((a, b) => a - b) + const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0 + return { + fps: total ? Math.round((frames.length / total) * 1000 * 10) / 10 : 0, + p95: Math.round(pct(0.95) * 10) / 10, + worst: sorted.length ? Math.round(sorted[sorted.length - 1] * 10) / 10 : 0, + slow33: frames.filter(f => f > 33).length, + longFrames: lofs.slice(0, 10) + } + })() +` + +const DRAG_BODY = ` + const handle = document.querySelector('[role="separator"]') + if (!handle) throw new Error('no sash') + const box = handle.getBoundingClientRect() + const y = box.top + box.height / 2 + const x0 = box.left + box.width / 2 + let x = x0 + const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 } + handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y })) + for (let i = 0; i < 60; i++) { + x += (i < 30 ? 3 : -3) + window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y })) + await new Promise(r => requestAnimationFrame(r)) + } + window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y })) +` + +const TYPE_BODY = ` + const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => !e.closest('[data-pane-hidden]')) + if (!el) throw new Error('no composer') + el.focus() + for (let i = 0; i < 40; i++) { + const ch = 'the quick brown fox '[i % 20] + el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch })) + el.textContent += ch + el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' })) + el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch })) + await new Promise(r => requestAnimationFrame(r)) + } +` + +// Session switching: click each sidebar session row, time route->settled. +const SWITCH = ` + (async () => { + const pick = () => [...document.querySelectorAll('[data-slot="row-button"]')] + .filter(el => el.offsetParent !== null).slice(0, 8) + if (pick().length < 2) { + throw new Error('found ' + pick().length + ' session rows') + } + const times = [] + const labels = [] + for (let i = 0; i < Math.min(pick().length, 6); i++) { + // Re-query each iteration: a switch can re-render the sidebar and + // detach the previously captured nodes. + const row = pick()[i] + if (!row) break + labels.push((row.textContent || '').slice(0, 24)) + const t0 = performance.now() + row.click() + let calm = 0 + while (calm < 2 && performance.now() - t0 < 5000) { + const f0 = performance.now() + await new Promise(r => requestAnimationFrame(r)) + const dt = performance.now() - f0 + calm = dt < 20 ? calm + 1 : 0 + } + times.push(Math.round(performance.now() - t0)) + await new Promise(r => setTimeout(r, 400)) + } + return { switches: times, labels, avg: Math.round(times.reduce((a, b) => a + b, 0) / times.length) } + })() +` + +const cmd = process.argv[2] ?? 'status' +const arg = process.argv[3] +const { evaluate, close } = await attach() + +try { + if (cmd === 'status') { + const r = await evaluate(`JSON.stringify({ + url: location.hash, + perfLive: typeof window.__PERF_LIVE__ !== 'undefined', + renderCounts: typeof window.__RENDER_COUNTS__ !== 'undefined', + tiles: document.querySelectorAll('[data-tree-group]').length, + sessions: document.querySelectorAll('[data-slot*="session-row"], [data-session-row]').length, + composers: [...document.querySelectorAll('[contenteditable="true"]')].length + })`) + console.log(r) + } else if (cmd === 'fps') { + console.log(JSON.stringify(await evaluate(FPS(Number(arg ?? 4))))) + } else if (cmd === 'drag') { + const r = await evaluate(WITH_LOAF(DRAG_BODY)) + console.log('drag', JSON.stringify({ fps: r.fps, p95: r.p95, worst: r.worst, slow33: r.slow33 })) + for (const lf of r.longFrames) { + console.log(` ⏱ ${lf.ms}ms block=${lf.block} style=${lf.style} → ${lf.scripts.join(' | ') || '(no script ≥5ms)'}`) + } + } else if (cmd === 'type') { + const r = await evaluate(WITH_LOAF(TYPE_BODY)) + console.log('type', JSON.stringify({ fps: r.fps, p95: r.p95, worst: r.worst, slow33: r.slow33 })) + for (const lf of r.longFrames) { + console.log(` ⏱ ${lf.ms}ms block=${lf.block} style=${lf.style} → ${lf.scripts.join(' | ') || '(no script ≥5ms)'}`) + } + } else if (cmd === 'switch') { + console.log(JSON.stringify(await evaluate(SWITCH))) + } else if (cmd === 'eval') { + console.log(JSON.stringify(await evaluate(arg))) + } else if (cmd === 'profile') { + // CPU-profile one session switch via CDP Profiler (Document Policy blocks + // the in-page Profiler API, CDP is exempt). arg = row label prefix. + await send('Profiler.enable') + await send('Profiler.setSamplingInterval', { interval: 200 }) + await send('Profiler.start') + const r = await evaluate(` + (async () => { + const pick = () => [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent !== null) + const row = pick().find(el => (el.textContent || '').startsWith(${JSON.stringify(arg ?? 'GUI')})) + if (!row) return 'row not found' + const t0 = performance.now() + row.click() + let calm = 0 + while (calm < 3 && performance.now() - t0 < 6000) { + const f0 = performance.now() + await new Promise(r => requestAnimationFrame(r)) + calm = (performance.now() - f0) < 20 ? calm + 1 : 0 + } + return Math.round(performance.now() - t0) + })() + `) + const { profile } = await send('Profiler.stop') + // Self-time per function. + const hitById = new Map() + for (let i = 0; i < profile.samples.length; i++) { + const id = profile.samples[i] + const dt = profile.timeDeltas[i] ?? 0 + hitById.set(id, (hitById.get(id) ?? 0) + dt) + } + const rows = [] + for (const node of profile.nodes) { + const us = hitById.get(node.id) + if (!us || us < 5000) continue + const f = node.callFrame + rows.push([Math.round(us / 1000), `${f.functionName || '(anon)'} @ ${(f.url || '').split('/').pop()}:${f.lineNumber}`]) + } + rows.sort((a, b) => b[0] - a[0]) + console.log('switch took', r, 'ms — top self-time:') + for (const [ms, name] of rows.slice(0, 18)) { + console.log(` ${String(ms).padStart(6)}ms ${name}`) + } + } else if (cmd === 'send') { + const r = await evaluate(` + (async () => { + const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => !e.closest('[data-pane-hidden]')) + if (!el) return 'no composer' + el.focus() + document.execCommand('insertText', false, ${JSON.stringify(arg ?? 'hello')}) + await new Promise(r => setTimeout(r, 120)) + el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter' })) + return 'sent' + })() + `) + console.log(r) + } else { + console.log('unknown cmd', cmd) + } +} finally { + close() +} diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index 676a0f57136c..f227bce1d833 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -47,7 +47,14 @@ const RENDER_BUDGET = 300 // the user actually sees after scroll-to-bottom), then bump to the full budget // in a requestAnimationFrame — defers the heavy markdown+syntax-highlight render // past the initial commit, so the switch feels instant. -const FIRST_PAINT_BUDGET = 60 +// +// 20, down from 60: the first-paint commit is synchronous and uninterruptible, +// and at 60 parts it measured 627ms on a real session (LoAF: block=575ms, no +// attributed script — pure commit). A viewport after scroll-to-bottom shows +// 1-2 turns ≈ 10-20 parts; the transition backfill below fills the rest +// interruptibly, so the only thing a smaller budget changes is how much work +// blocks the click-to-paint path. +const FIRST_PAINT_BUDGET = 20 interface ThreadMessageListProps { clampToComposer: boolean @@ -210,8 +217,18 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({ // changes stay urgent (main.tsx disables router transitions); it's exactly // this backfill that belongs at background priority. "Show earlier" pages // (budget > RENDER_BUDGET) never re-enter here. + // + // NOT while this thread is streaming: an interrupted transition RESTARTS + // from scratch, and stream flushes land every 33-250ms — so switching to a + // streaming session re-ran the whole 300-part backfill render over and + // over (measured: 1374ms settle, 30 commits, Primitive.div x2237 on one + // switch; the same switch while idle settles in ~50ms). The user lands at + // the live tail anyway; older turns backfill the moment the run ends, and + // "Show earlier" remains the manual path meanwhile. + const threadRunning = useAuiState(s => s.thread.isRunning) + useEffect(() => { - if (renderBudget >= RENDER_BUDGET) { + if (renderBudget >= RENDER_BUDGET || threadRunning) { return } @@ -223,7 +240,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({ }) return () => cancelAnimationFrame(rafId) - }, [renderBudget]) + }, [renderBudget, threadRunning]) // Weights (per-message part counts) fold into the BUDGET only. Group // identity stays structural, so a streaming append re-runs this cheap sum — From 28a87d6319b847260f876b249084fa7fa0e88392 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Mon, 27 Jul 2026 01:26:34 -0500 Subject: [PATCH 42/71] fix(cli): scope -c/--resume to the current workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes -c`/`--resume` (continue last session) resolved the globally most-recently-used session, then cd'd into *its* recorded cwd. So running `hermes -c` from repo A could land you in repo B's session — the session you last touched anywhere, not the last one *here*. Now `_resolve_last_session` scopes to the current workspace first: the git repo root when CWD is inside a repo (so all sessions across its subdirs/worktrees group together), else the CWD itself — matching the `workspace_key` identity `hermes sessions list --workspace` already groups on. It falls back to the unscoped global MRU when no session matches the current workspace, preserving the old behaviour for fresh directories. Adds `workspace_key` param to `SessionDB.search_sessions` and a `_workspace_key_clause` SQL helper that mirrors `workspace_key()`: a row matches when its `git_repo_root` equals the key, or (legacy rows without git metadata) when its `cwd` is at or under it. --- hermes_cli/main.py | 38 +++- hermes_state.py | 54 +++-- tests/hermes_cli/test_resolve_last_session.py | 195 ++++++++++++++++++ 3 files changed, 273 insertions(+), 14 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 95e79e905fd9..3f3d5680647d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1304,13 +1304,49 @@ def _session_browse_picker(sessions: list) -> Optional[str]: return None +def _resolve_workspace_key() -> Optional[str]: + """The current workspace identity for cwd-scoped resume. + + Git repo root when CWD is inside a repo (so all sessions across its + subdirs/worktrees group together), else the CWD itself. Returns None when + neither can be determined — callers fall back to the global MRU then. + """ + try: + import subprocess + + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + return os.path.abspath(result.stdout.strip()) + except Exception: + pass + try: + return os.getcwd() + except Exception: + return None + + def _resolve_last_session(source: str = "cli") -> Optional[str]: - """Look up the most recently-used session ID for a source.""" + """Look up the most recently-used session ID for a source. + + Scoped to the current workspace first (git repo root, else cwd) so + ``hermes -c`` from repo A continues repo A's last session rather than the + global MRU. Falls back to the unscoped MRU when no session matches the + current workspace, preserving the old behaviour for fresh directories. + """ db = None try: from hermes_state import SessionDB db = SessionDB() + ws_key = _resolve_workspace_key() + if ws_key: + sessions = db.search_sessions(source=source, limit=1, workspace_key=ws_key) + if sessions: + return sessions[0]["id"] + # Fallback: global MRU for this source. sessions = db.search_sessions(source=source, limit=1) return sessions[0]["id"] if sessions else None except Exception: diff --git a/hermes_state.py b/hermes_state.py index 9ad8761670dc..f309acce1277 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -135,6 +135,24 @@ def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]: return "(s.cwd = ? OR s.cwd LIKE ? OR s.cwd LIKE ?)", [prefix, f"{prefix}/%", f"{prefix}\\%"] +def _workspace_key_clause(key: str) -> Tuple[str, List[str]]: + """Match sessions whose ``workspace_key(row)`` equals ``key``. + + Mirrors :func:`workspace_key`: a session belongs to workspace ``key`` + when its recorded ``git_repo_root`` equals ``key``, or — for rows that + predate per-session git metadata — when its ``cwd`` is at or under + ``key`` (so a session started in ``repo/src`` still groups with ``repo``). + Used by ``hermes -c``/``--resume`` to continue the most recent session in + the *current* workspace rather than the global MRU. + """ + prefix = key.rstrip("/\\") or key + cwd_clause, cwd_params = _cwd_prefix_clause(prefix) + return ( + f"(s.git_repo_root = ? OR (COALESCE(s.git_repo_root, '') = '' AND {cwd_clause}))", + [prefix, *cwd_params], + ) + + # Session preview = the head of the first user message, shown wherever a # session has no title (sidebar rows, pickers, exports, the desktop's # `sessionTitle` fallback). @@ -8656,12 +8674,18 @@ class SessionDB: source: str = None, limit: int = 20, offset: int = 0, + workspace_key: str = None, ) -> List[Dict[str, Any]]: """List sessions, optionally filtered by source. Returns rows enriched with a computed ``last_active`` column (latest message timestamp for the session, falling back to ``started_at``), ordered by most-recently-used first. + + Pass ``workspace_key`` to scope rows to one workspace — matching + :func:`workspace_key` semantics (git repo root, else cwd). Used by + ``hermes -c``/``--resume`` so the "last" session is the last one in + the *current* workspace, not the global MRU. """ select_with_last_active = ( "SELECT s.*, COALESCE(m.last_active, s.started_at) AS last_active " @@ -8671,20 +8695,24 @@ class SessionDB: "FROM messages GROUP BY session_id" ") m ON m.session_id = s.id " ) + where_clauses = [] + params: list = [] + if source: + where_clauses.append("s.source = ?") + params.append(source) + if workspace_key: + ws_clause, ws_params = _workspace_key_clause(workspace_key) + where_clauses.append(ws_clause) + params.extend(ws_params) + where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + params.extend([limit, offset]) with self._lock: - if source: - cursor = self._conn.execute( - f"{select_with_last_active}" - "WHERE s.source = ? " - "ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?", - (source, limit, offset), - ) - else: - cursor = self._conn.execute( - f"{select_with_last_active}" - "ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?", - (limit, offset), - ) + cursor = self._conn.execute( + f"{select_with_last_active}" + f"{where_sql} " + "ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?", + params, + ) return [dict(row) for row in cursor.fetchall()] # ========================================================================= diff --git a/tests/hermes_cli/test_resolve_last_session.py b/tests/hermes_cli/test_resolve_last_session.py index 1a82d1a79923..acd54b9a075e 100644 --- a/tests/hermes_cli/test_resolve_last_session.py +++ b/tests/hermes_cli/test_resolve_last_session.py @@ -155,3 +155,198 @@ def test_resolve_last_session_not_limited_to_newest_started_20(tmp_path, monkeyp monkeypatch.setattr("hermes_state.SessionDB", lambda: real_session_db(db_path=state_db)) assert _resolve_last_session("cli") == target + + +# --------------------------------------------------------------------------- +# cwd-scoped resume: -c prefers the last session in the current workspace. +# --------------------------------------------------------------------------- + + +class _WorkspaceAwareDB: + """Fake SessionDB whose ``search_sessions`` honors ``workspace_key`` the + same way the real ``_workspace_key_clause`` does: a row matches the key + when its ``git_repo_root`` equals it, or (no repo root recorded) when its + ``cwd`` is at or under it.""" + + def __init__(self, rows): + self._rows = rows + self.closed = False + + def search_sessions(self, source=None, limit=20, workspace_key=None, **_kw): + rows = [r for r in self._rows if r.get("source") == source] if source else list(self._rows) + if workspace_key: + key = workspace_key.rstrip("/") + def _in_ws(r): + grr = (r.get("git_repo_root") or "").rstrip("/") + if grr: + return grr == key + cwd = (r.get("cwd") or "").rstrip("/") + return cwd == key or cwd.startswith(key + "/") + rows = [r for r in rows if _in_ws(r)] + rows.sort( + key=lambda r: float(r.get("last_active") or r.get("started_at") or 0), + reverse=True, + ) + return rows[:limit] + + def close(self): + self.closed = True + + +def test_resolve_last_session_prefers_workspace_session_over_global_mru(monkeypatch, tmp_path): + # Two sessions: a globally-recent one in repo B, an older one in repo A. + # CWD is repo A → -c must pick repo A's session, not the global MRU. + repo_a = tmp_path / "repo-a" + repo_b = tmp_path / "repo-b" + repo_a.mkdir() + repo_b.mkdir() + monkeypatch.chdir(repo_a) + # Pretend both are git repo roots so _resolve_workspace_key returns the dir. + monkeypatch.setattr( + "hermes_cli.main.subprocess.run", + lambda cmd, **kw: __import__("subprocess").CompletedProcess( + cmd, 0, stdout=str(repo_a), stderr="" + ), + ) + + rows = [ + {"id": "global_recent", "source": "cli", "started_at": 9000.0, + "last_active": 9000.0, "cwd": str(repo_b), "git_repo_root": str(repo_b)}, + {"id": "repo_a_older", "source": "cli", "started_at": 1000.0, + "last_active": 1000.0, "cwd": str(repo_a), "git_repo_root": str(repo_a)}, + ] + monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows)) + assert _resolve_last_session("cli") == "repo_a_older" + + +def test_resolve_last_session_falls_back_to_global_when_workspace_empty(monkeypatch, tmp_path): + # CWD has no matching session → fall back to the global MRU. + repo_a = tmp_path / "repo-a" + repo_a.mkdir() + monkeypatch.chdir(repo_a) + monkeypatch.setattr( + "hermes_cli.main.subprocess.run", + lambda cmd, **kw: __import__("subprocess").CompletedProcess( + cmd, 0, stdout=str(repo_a), stderr="" + ), + ) + rows = [ + {"id": "elsewhere", "source": "cli", "started_at": 9000.0, + "last_active": 9000.0, "cwd": "/other/repo", "git_repo_root": "/other/repo"}, + ] + monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows)) + assert _resolve_last_session("cli") == "elsewhere" + + +def test_resolve_last_session_workspace_matches_cwd_subdir_without_git_root(monkeypatch, tmp_path): + # No git repo root on the session (legacy row) — match via cwd prefix so a + # session started in repo/src still resumes from repo/. + repo = tmp_path / "repo" + repo.mkdir() + monkeypatch.chdir(repo) + monkeypatch.setattr( + "hermes_cli.main.subprocess.run", + lambda cmd, **kw: __import__("subprocess").CompletedProcess( + cmd, 1, stdout="", stderr="not a repo" + ), + ) + rows = [ + {"id": "subdir_session", "source": "cli", "started_at": 500.0, + "last_active": 500.0, "cwd": str(repo / "src"), "git_repo_root": ""}, + ] + monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows)) + assert _resolve_last_session("cli") == "subdir_session" + + +def test_resolve_last_session_workspace_key_filters_by_source(monkeypatch, tmp_path): + # A TUI session in repo A is newer than a CLI session in repo A; asking for + # source="cli" must skip the TUI row even within the same workspace. + repo = tmp_path / "repo" + repo.mkdir() + monkeypatch.chdir(repo) + monkeypatch.setattr( + "hermes_cli.main.subprocess.run", + lambda cmd, **kw: __import__("subprocess").CompletedProcess( + cmd, 0, stdout=str(repo), stderr="" + ), + ) + rows = [ + {"id": "tui_recent", "source": "tui", "started_at": 9000.0, + "last_active": 9000.0, "cwd": str(repo), "git_repo_root": str(repo)}, + {"id": "cli_older", "source": "cli", "started_at": 1000.0, + "last_active": 1000.0, "cwd": str(repo), "git_repo_root": str(repo)}, + ] + monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows)) + assert _resolve_last_session("cli") == "cli_older" + + +def test_search_sessions_workspace_key_filters_at_sql_level(tmp_path, monkeypatch): + # End-to-end: search_sessions(workspace_key=...) must filter by + # git_repo_root, and fall back to cwd-prefix for rows without one. + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + import hermes_state + from pathlib import Path + + db = hermes_state.SessionDB(db_path=Path(tmp_path / "state.db")) + try: + # repo A: two sessions — one with git_repo_root, one legacy (cwd only). + db.create_session("repo_a_rooted", source="cli", cwd=str(tmp_path / "repo-a"), + git_repo_root=str(tmp_path / "repo-a")) + db.create_session("repo_a_legacy", source="cli", cwd=str(tmp_path / "repo-a" / "src")) + # repo B: one session, globally most recent. + db.create_session("repo_b", source="cli", cwd=str(tmp_path / "repo-b"), + git_repo_root=str(tmp_path / "repo-b")) + with db._lock: + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (100.0, "repo_a_rooted")) + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (50.0, "repo_a_legacy")) + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (9000.0, "repo_b")) + db._conn.commit() + + key = str(tmp_path / "repo-a") + rows = db.search_sessions(source="cli", limit=10, workspace_key=key) + ids = sorted(r["id"] for r in rows) + assert ids == ["repo_a_legacy", "repo_a_rooted"] + assert "repo_b" not in ids + + # No workspace_key → global MRU includes repo_b. + all_rows = db.search_sessions(source="cli", limit=10) + assert all_rows[0]["id"] == "repo_b" + finally: + db.close() + + +def test_resolve_last_session_real_db_prefers_workspace(monkeypatch, tmp_path): + # End-to-end through the real SessionDB + _resolve_last_session: -c from + # repo A picks repo A's session even though repo B is globally newer. + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + import hermes_state + from pathlib import Path + + repo_a = tmp_path / "repo-a" + repo_a.mkdir() + state_db = Path(tmp_path / "state.db") + real_db = hermes_state.SessionDB + db = real_db(db_path=state_db) + try: + db.create_session("repo_a", source="cli", cwd=str(repo_a), git_repo_root=str(repo_a)) + db.create_session("repo_b", source="cli", cwd="/other/repo-b", git_repo_root="/other/repo-b") + with db._lock: + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (100.0, "repo_a")) + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (9000.0, "repo_b")) + db._conn.commit() + finally: + db.close() + + monkeypatch.chdir(repo_a) + monkeypatch.setattr( + "hermes_cli.main.subprocess.run", + lambda cmd, **kw: __import__("subprocess").CompletedProcess( + cmd, 0, stdout=str(repo_a), stderr="" + ), + ) + monkeypatch.setattr("hermes_state.SessionDB", lambda: real_db(db_path=state_db)) + assert _resolve_last_session("cli") == "repo_a" From 1865fb5fcd702ba3409a67e0b6642e63c8e97b3a Mon Sep 17 00:00:00 2001 From: Tony Simons <asimons81@gmail.com> Date: Sun, 12 Jul 2026 13:49:45 -0500 Subject: [PATCH 43/71] feat(plugins): add public subagent lifecycle API --- agent/subagent_lifecycle.py | 481 ++++++++++++++++++ hermes_cli/plugins.py | 17 + tests/agent/test_subagent_lifecycle.py | 98 ++++ .../developer-guide/subagent-lifecycle-api.md | 54 ++ 4 files changed, 650 insertions(+) create mode 100644 agent/subagent_lifecycle.py create mode 100644 tests/agent/test_subagent_lifecycle.py create mode 100644 website/docs/developer-guide/subagent-lifecycle-api.md diff --git a/agent/subagent_lifecycle.py b/agent/subagent_lifecycle.py new file mode 100644 index 000000000000..5b3aa84aea7c --- /dev/null +++ b/agent/subagent_lifecycle.py @@ -0,0 +1,481 @@ +"""Public, plugin-safe lifecycle API for delegated Hermes subagents. + +This module deliberately exposes immutable contracts, not ``AIAgent`` objects. +It is the supported boundary for plugins that need to supervise fresh child +sessions; plugins must obtain it from ``PluginContext.subagent_lifecycle``. +""" + +from __future__ import annotations + +import dataclasses +import enum +import hashlib +import hmac +import json +import secrets +import threading +import time +from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError +from typing import Any, Callable, Mapping, Optional + + +PUBLIC_CONTRACT_VERSION = 1 +_MAX_GOAL_CHARS = 16_000 +_MAX_CONTEXT_CHARS = 32_000 +_MAX_METADATA_BYTES = 8_192 +_MAX_RESULT_CHARS = 32_000 +_TERMINAL_RETENTION_SECONDS = 3_600 + + +class SubagentLifecycleError(ValueError): + """A request cannot be safely accepted by the public lifecycle API.""" + + +class SubagentState(str, enum.Enum): + PENDING = "PENDING" + STARTING = "STARTING" + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + INTERRUPTED = "INTERRUPTED" + CANCEL_REQUESTED = "CANCEL_REQUESTED" + CANCELLED = "CANCELLED" + UNKNOWN = "UNKNOWN" + + +@dataclasses.dataclass(frozen=True) +class SubagentLaunchRequest: + goal: str + context: Optional[str] = None + role: str = "leaf" + model: Optional[str] = None + allowed_toolsets: Optional[tuple[str, ...]] = None + blocked_tools: tuple[str, ...] = () + working_directory: Optional[str] = None + parent_session_id: Optional[str] = None + correlation_id: Optional[str] = None + metadata: Mapping[str, Any] = dataclasses.field(default_factory=dict) + timeout_seconds: Optional[float] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentHandle: + contract_version: int + subagent_id: str + parent_session_id: Optional[str] + correlation_id: Optional[str] + created_at: float + provider: Optional[str] + model: Optional[str] + role: str + depth: int + capability: str + + def to_dict(self) -> dict[str, Any]: + return dataclasses.asdict(self) + + @classmethod + def from_dict(cls, value: Mapping[str, Any]) -> "SubagentHandle": + try: + return cls(**dict(value)) + except (TypeError, ValueError) as exc: + raise SubagentLifecycleError("Malformed subagent handle.") from exc + + +@dataclasses.dataclass(frozen=True) +class SubagentStatus: + handle: SubagentHandle + state: SubagentState + updated_at: float + diagnostic: Optional[str] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentTerminalState: + handle: SubagentHandle + state: SubagentState + completed: bool + timed_out: bool = False + diagnostic: Optional[str] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentCancelResult: + accepted: bool + already_terminal: bool = False + unknown_handle: bool = False + unsupported: bool = False + state: SubagentState = SubagentState.UNKNOWN + + +@dataclasses.dataclass(frozen=True) +class SubagentResult: + handle: SubagentHandle + terminal_state: SubagentState + ready: bool + summary: Optional[str] = None + structured_payload: Optional[Mapping[str, Any]] = None + started_at: Optional[float] = None + completed_at: Optional[float] = None + error_classification: Optional[str] = None + error_message: Optional[str] = None + usage_metadata: Mapping[str, Any] = dataclasses.field(default_factory=dict) + tool_execution_summary: Mapping[str, Any] = dataclasses.field(default_factory=dict) + result_hash: Optional[str] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentReconnectResult: + connected: bool + state: SubagentState + diagnostic: Optional[str] = None + + +@dataclasses.dataclass +class _Record: + handle: SubagentHandle + state: SubagentState + updated_at: float + agent: Any = None + future: Optional[Future] = None + started_at: Optional[float] = None + completed_at: Optional[float] = None + result: Optional[SubagentResult] = None + + +class _Registry: + """Thread-safe terminal-retention registry; never returns live records.""" + + def __init__(self) -> None: + self.lock = threading.RLock() + self.records: dict[str, _Record] = {} + self.correlations: dict[tuple[Optional[str], str], str] = {} + + +_REGISTRY = _Registry() +_EXECUTOR = ThreadPoolExecutor(max_workers=8, thread_name_prefix="hermes-lifecycle") +_SECRET = secrets.token_bytes(32) + + +class SubagentLifecycleService: + """Stable public service returned by :attr:`PluginContext.subagent_lifecycle`. + + Running children are in-process only. Completed results remain available + until process exit; ``reconnect`` accurately reports that a serialized + handle cannot reconnect after a restart instead of launching work again. + """ + + def __init__(self, parent_agent_resolver: Callable[[], Any]) -> None: + self._parent_agent_resolver = parent_agent_resolver + + def launch(self, request: SubagentLaunchRequest) -> SubagentHandle: + parent = self._parent_agent_resolver() + if parent is None: + raise SubagentLifecycleError( + "No active Hermes parent session is available." + ) + self._validate_request(request, parent) + parent_session_id = str(getattr(parent, "session_id", "") or "") or None + if request.parent_session_id and request.parent_session_id != parent_session_id: + raise SubagentLifecycleError( + "parent_session_id does not match the active session." + ) + correlation_key = (parent_session_id, request.correlation_id or "") + with _REGISTRY.lock: + self._cleanup_locked() + if request.correlation_id and correlation_key in _REGISTRY.correlations: + raise SubagentLifecycleError( + "Duplicate correlation_id for this parent session." + ) + + # Delegate construction remains internal so plugin code never imports + # private delegation helpers or manipulates the active-child registry. + from tools.delegate_tool import _build_child_agent, DEFAULT_MAX_ITERATIONS + + child = _build_child_agent( + task_index=0, + goal=request.goal, + context=request.context, + toolsets=list(request.allowed_toolsets) + if request.allowed_toolsets + else None, + model=request.model, + max_iterations=DEFAULT_MAX_ITERATIONS, + task_count=1, + parent_agent=parent, + role=request.role, + ) + subagent_id = str(getattr(child, "_subagent_id", "") or "") + if not subagent_id: + raise SubagentLifecycleError("Hermes failed to assign a child identity.") + created = time.time() + handle = SubagentHandle( + PUBLIC_CONTRACT_VERSION, + subagent_id, + parent_session_id, + request.correlation_id, + created, + getattr(child, "provider", None), + getattr(child, "model", None), + getattr(child, "_delegate_role", request.role), + int(getattr(child, "_delegate_depth", 1) or 1), + self._capability(subagent_id, parent_session_id, created), + ) + record = _Record(handle, SubagentState.PENDING, created, agent=child) + with _REGISTRY.lock: + _REGISTRY.records[subagent_id] = record + if request.correlation_id: + _REGISTRY.correlations[correlation_key] = subagent_id + record.future = _EXECUTOR.submit(self._run, record, request.goal, parent) + return handle + + def status(self, handle: SubagentHandle) -> SubagentStatus: + record = self._record(handle) + if record is None: + return SubagentStatus( + handle, SubagentState.UNKNOWN, time.time(), "UNKNOWN_HANDLE" + ) + with _REGISTRY.lock: + return SubagentStatus(record.handle, record.state, record.updated_at) + + def wait( + self, handle: SubagentHandle, *, timeout_seconds: Optional[float] = None + ) -> SubagentTerminalState: + record = self._record(handle) + if record is None: + return SubagentTerminalState( + handle, SubagentState.UNKNOWN, True, diagnostic="UNKNOWN_HANDLE" + ) + future = record.future + if future is not None: + try: + future.result(timeout=timeout_seconds) + except TimeoutError: + return SubagentTerminalState(record.handle, record.state, False, True) + except Exception: + pass + with _REGISTRY.lock: + return SubagentTerminalState( + record.handle, record.state, record.result is not None + ) + + def cancel(self, handle: SubagentHandle, *, reason: str) -> SubagentCancelResult: + record = self._record(handle) + if record is None: + return SubagentCancelResult(False, unknown_handle=True) + with _REGISTRY.lock: + if record.result is not None: + return SubagentCancelResult( + False, already_terminal=True, state=record.state + ) + agent = record.agent + record.state = SubagentState.CANCEL_REQUESTED + record.updated_at = time.time() + if agent is None or not hasattr(agent, "interrupt"): + return SubagentCancelResult( + False, unsupported=True, state=SubagentState.CANCEL_REQUESTED + ) + try: + agent.interrupt(f"Lifecycle cancellation requested: {reason[:500]}") + except Exception: + return SubagentCancelResult( + False, unsupported=True, state=SubagentState.CANCEL_REQUESTED + ) + return SubagentCancelResult(True, state=SubagentState.CANCEL_REQUESTED) + + def result(self, handle: SubagentHandle) -> SubagentResult: + record = self._record(handle) + if record is None: + return SubagentResult( + handle, + SubagentState.UNKNOWN, + False, + error_classification="UNKNOWN_HANDLE", + ) + with _REGISTRY.lock: + if record.result is not None: + return record.result + return SubagentResult( + record.handle, record.state, False, error_classification="NOT_READY" + ) + + def reconnect(self, handle: SubagentHandle) -> SubagentReconnectResult: + record = self._record(handle) + if record is None: + return SubagentReconnectResult( + False, SubagentState.UNKNOWN, "RECONNECT_UNAVAILABLE" + ) + with _REGISTRY.lock: + return SubagentReconnectResult(True, record.state) + + def _record(self, handle: SubagentHandle) -> Optional[_Record]: + if ( + not isinstance(handle, SubagentHandle) + or handle.contract_version != PUBLIC_CONTRACT_VERSION + ): + return None + if not hmac.compare_digest( + handle.capability, + self._capability( + handle.subagent_id, handle.parent_session_id, handle.created_at + ), + ): + return None + parent = self._parent_agent_resolver() + active_parent_id = str(getattr(parent, "session_id", "") or "") or None + if active_parent_id != handle.parent_session_id: + return None + with _REGISTRY.lock: + return _REGISTRY.records.get(handle.subagent_id) + + @staticmethod + def _cleanup_locked() -> None: + """Retain terminal snapshots for a bounded period, never live work.""" + cutoff = time.time() - _TERMINAL_RETENTION_SECONDS + expired = [ + subagent_id + for subagent_id, record in _REGISTRY.records.items() + if record.result is not None + and record.completed_at is not None + and record.completed_at < cutoff + ] + for subagent_id in expired: + record = _REGISTRY.records.pop(subagent_id) + if record.handle.correlation_id: + _REGISTRY.correlations.pop( + (record.handle.parent_session_id, record.handle.correlation_id), + None, + ) + + def _run(self, record: _Record, goal: str, parent: Any) -> None: + with _REGISTRY.lock: + record.state = SubagentState.RUNNING + record.started_at = time.time() + record.updated_at = record.started_at + try: + from tools.delegate_tool import _run_single_child + + raw = _run_single_child(0, goal, record.agent, parent) + status = ( + str(raw.get("status", "error")) if isinstance(raw, dict) else "error" + ) + if status == "completed": + state = SubagentState.SUCCEEDED + elif status == "interrupted": + state = ( + SubagentState.CANCELLED + if record.state == SubagentState.CANCEL_REQUESTED + else SubagentState.INTERRUPTED + ) + else: + state = SubagentState.FAILED + summary = raw.get("summary") if isinstance(raw, dict) else None + summary = str(summary)[:_MAX_RESULT_CHARS] if summary is not None else None + error = raw.get("error") if isinstance(raw, dict) else None + result = SubagentResult( + record.handle, + state, + True, + summary=summary, + completed_at=time.time(), + started_at=record.started_at, + error_classification=None + if state == SubagentState.SUCCEEDED + else status.upper(), + error_message=str(error)[:_MAX_RESULT_CHARS] if error else None, + usage_metadata={"api_calls": raw.get("api_calls", 0)} + if isinstance(raw, dict) + else {}, + tool_execution_summary={ + "duration_seconds": raw.get("duration_seconds", 0) + } + if isinstance(raw, dict) + else {}, + ) + except Exception as exc: + result = SubagentResult( + record.handle, + SubagentState.FAILED, + True, + started_at=record.started_at, + completed_at=time.time(), + error_classification=type(exc).__name__, + error_message=str(exc)[:_MAX_RESULT_CHARS], + ) + payload = dataclasses.asdict(result) + payload.pop("result_hash", None) + result = dataclasses.replace( + result, + result_hash=hashlib.sha256( + json.dumps(payload, sort_keys=True, default=str).encode() + ).hexdigest(), + ) + with _REGISTRY.lock: + record.agent = None + record.result = result + record.state = result.terminal_state + record.completed_at = result.completed_at + record.updated_at = result.completed_at or time.time() + + @staticmethod + def _capability( + subagent_id: str, parent_session_id: Optional[str], created_at: float + ) -> str: + value = f"{subagent_id}|{parent_session_id or ''}|{created_at:.6f}".encode() + return hmac.new(_SECRET, value, hashlib.sha256).hexdigest() + + @staticmethod + def _validate_request(request: SubagentLaunchRequest, parent: Any) -> None: + if ( + not isinstance(request, SubagentLaunchRequest) + or not isinstance(request.goal, str) + or not request.goal.strip() + or len(request.goal) > _MAX_GOAL_CHARS + ): + raise SubagentLifecycleError( + "goal must be a non-empty string of at most 16000 characters." + ) + if request.context is not None and ( + not isinstance(request.context, str) + or len(request.context) > _MAX_CONTEXT_CHARS + ): + raise SubagentLifecycleError( + "context must be a string of at most 32000 characters." + ) + if request.role not in {"leaf", "orchestrator"}: + raise SubagentLifecycleError("role must be 'leaf' or 'orchestrator'.") + if request.timeout_seconds is not None: + raise SubagentLifecycleError( + "Per-launch timeout is not supported; configure delegation timeout explicitly." + ) + if request.working_directory is not None: + raise SubagentLifecycleError( + "working_directory is not supported because Hermes delegates use isolated task environments." + ) + if request.blocked_tools: + raise SubagentLifecycleError( + "Per-tool blocking is not supported; use allowed_toolsets. Hermes always blocks unsafe child tools." + ) + try: + metadata_bytes = len( + json.dumps(dict(request.metadata), sort_keys=True).encode() + ) + except (TypeError, ValueError) as exc: + raise SubagentLifecycleError("metadata must be JSON-serializable.") from exc + if metadata_bytes > _MAX_METADATA_BYTES: + raise SubagentLifecycleError("metadata exceeds 8192 bytes.") + if request.allowed_toolsets: + from toolsets import TOOLSETS + + unknown = set(request.allowed_toolsets) - set(TOOLSETS) + if unknown: + raise SubagentLifecycleError( + f"Unknown toolsets: {', '.join(sorted(unknown))}." + ) + enabled = getattr(parent, "enabled_toolsets", None) + if enabled is not None and not set(request.allowed_toolsets).issubset( + set(enabled) + ): + raise SubagentLifecycleError( + "Requested toolsets would broaden parent permissions." + ) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 80142c15db1d..20eb30359c2b 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -344,6 +344,7 @@ class PluginContext: self._manager = manager # Lazy-built host-owned LLM facade — see ctx.llm property below. self._llm: Any = None + self._subagent_lifecycle: Any = None # -- host-owned LLM access ---------------------------------------------- @@ -364,6 +365,22 @@ class PluginContext: self._llm = PluginLlm(plugin_id=plugin_id) return self._llm + @property + def subagent_lifecycle(self) -> Any: + """Return the public, plugin-safe subagent lifecycle service. + + The service only resolves the active host-owned parent agent when a + child is launched. Plugins receive serializable handles and immutable + snapshots; they never receive a live agent or a private registry. + """ + if self._subagent_lifecycle is None: + from agent.subagent_lifecycle import SubagentLifecycleService + self._subagent_lifecycle = SubagentLifecycleService( + lambda: getattr(self._manager._cli_ref, "agent", None) + if self._manager._cli_ref is not None else None + ) + return self._subagent_lifecycle + # -- profile awareness -------------------------------------------------- @property diff --git a/tests/agent/test_subagent_lifecycle.py b/tests/agent/test_subagent_lifecycle.py new file mode 100644 index 000000000000..409d10faa894 --- /dev/null +++ b/tests/agent/test_subagent_lifecycle.py @@ -0,0 +1,98 @@ +"""Contract tests for the public plugin subagent lifecycle API.""" + +import time +from types import SimpleNamespace + +import pytest + +from agent.subagent_lifecycle import ( + SubagentLaunchRequest, + SubagentLifecycleError, + SubagentLifecycleService, + SubagentState, +) + + +class FakeChild: + def __init__(self, ident="sa-test"): + self._subagent_id = ident + self._delegate_role = "leaf" + self._delegate_depth = 1 + self.provider = "test" + self.model = "test-model" + self.interrupted = False + + def interrupt(self, _reason): + self.interrupted = True + + +@pytest.fixture +def lifecycle(monkeypatch): + parent = SimpleNamespace(session_id="parent-1", enabled_toolsets=["file"]) + counter = iter(range(1000)) + + def build(**_kwargs): + return FakeChild(f"sa-{next(counter)}") + + def run(_index, _goal, child, _parent): + for _ in range(20): + if child.interrupted: + return { + "status": "interrupted", + "summary": None, + "api_calls": 0, + "duration_seconds": 0, + } + time.sleep(0.002) + return { + "status": "completed", + "summary": "safe summary", + "api_calls": 1, + "duration_seconds": 0.01, + } + + monkeypatch.setattr("tools.delegate_tool._build_child_agent", build) + monkeypatch.setattr("tools.delegate_tool._run_single_child", run) + return SubagentLifecycleService(lambda: parent) + + +def test_launch_wait_result_and_handle_round_trip(lifecycle): + handle = lifecycle.launch( + SubagentLaunchRequest(goal="x", allowed_toolsets=("file",)) + ) + assert handle.from_dict(handle.to_dict()) == handle + assert lifecycle.wait(handle, timeout_seconds=1).state is SubagentState.SUCCEEDED + first = lifecycle.result(handle) + assert first.ready and first.summary == "safe summary" and first.result_hash + assert lifecycle.result(handle) == first + + +def test_duplicate_correlation_and_permission_validation(lifecycle): + lifecycle.launch(SubagentLaunchRequest(goal="x", correlation_id="same")) + with pytest.raises(SubagentLifecycleError, match="Duplicate"): + lifecycle.launch(SubagentLaunchRequest(goal="x", correlation_id="same")) + with pytest.raises(SubagentLifecycleError, match="broaden"): + lifecycle.launch( + SubagentLaunchRequest(goal="x", allowed_toolsets=("terminal",)) + ) + with pytest.raises(SubagentLifecycleError, match="working_directory"): + lifecycle.launch(SubagentLaunchRequest(goal="x", working_directory="C:/")) + + +def test_cancel_is_cooperative_and_forged_handle_is_unknown(lifecycle): + handle = lifecycle.launch(SubagentLaunchRequest(goal="x")) + assert lifecycle.cancel(handle, reason="test").accepted + terminal = lifecycle.wait(handle, timeout_seconds=1) + assert terminal.state is SubagentState.CANCELLED + forged = handle.__class__(**{**handle.to_dict(), "capability": "forged"}) + assert lifecycle.status(forged).state is SubagentState.UNKNOWN + assert lifecycle.result(forged).error_classification == "UNKNOWN_HANDLE" + other_parent = SimpleNamespace(session_id="different-parent") + other_service = SubagentLifecycleService(lambda: other_parent) + assert other_service.status(handle).state is SubagentState.UNKNOWN + + +def test_simultaneous_launches_are_distinct_and_reconnect_is_in_process(lifecycle): + handles = [lifecycle.launch(SubagentLaunchRequest(goal="x")) for _ in range(10)] + assert len({h.subagent_id for h in handles}) == 10 + assert lifecycle.reconnect(handles[0]).connected diff --git a/website/docs/developer-guide/subagent-lifecycle-api.md b/website/docs/developer-guide/subagent-lifecycle-api.md new file mode 100644 index 000000000000..6c2dcac53c53 --- /dev/null +++ b/website/docs/developer-guide/subagent-lifecycle-api.md @@ -0,0 +1,54 @@ +--- +title: Public Subagent Lifecycle API +sidebar_label: Subagent lifecycle API +--- + +# Public Subagent Lifecycle API + +Plugins can launch and supervise fresh Hermes child sessions without importing +`tools.delegate_tool`, gateway internals, TUI state, or `AIAgent` fields. + +```python +from agent.subagent_lifecycle import SubagentLaunchRequest + +def register(ctx): + service = ctx.subagent_lifecycle + handle = service.launch(SubagentLaunchRequest( + goal="Review this change for regressions.", + context="Only inspect the supplied repository.", + role="leaf", + correlation_id="review-42", + allowed_toolsets=("file",), + )) + # Persist handle.to_dict() if desired. + if service.wait(handle, timeout_seconds=2).timed_out: + return handle.to_dict() + return service.result(handle) +``` + +`SubagentHandle` is serializable and carries a versioned, opaque capability. +Pass it back to `status`, `wait`, `cancel`, `result`, or `reconnect`; malformed +or forged handles return `UNKNOWN`/`UNKNOWN_HANDLE` and cannot access a child. + +The stable states are `PENDING`, `STARTING`, `RUNNING`, `SUCCEEDED`, `FAILED`, +`INTERRUPTED`, `CANCEL_REQUESTED`, `CANCELLED`, and `UNKNOWN`. + +`cancel(handle, reason=...)` is cooperative: it asks the child agent to +interrupt at its next safe boundary and returns `CANCEL_REQUESTED`; it never +claims completion until `wait` or `result` observes a terminal state. Terminal +results are immutable, idempotent, bounded to 32k characters, omit transcripts +and hidden reasoning, and include a stable result hash. + +This API is lifecycle-managed asynchronous execution. It does not change the +synchronous `delegate_task` tool, batch delegation, or its gateway/TUI display. +The initial implementation retains metadata and terminal results in-process for +one hour. +After a process restart, `reconnect` returns `RECONNECT_UNAVAILABLE` and never +starts a replacement child. Running Python threads also cannot survive process +exit; callers must treat those handles as interrupted by process exit. + +Requests are fail-closed: goal/context/metadata sizes are capped, unknown or +parent-broadening toolsets are rejected, and per-tool blocks, working-directory +overrides, and per-launch timeouts are explicitly rejected until Hermes can +support them without weakening isolation. Use `allowed_toolsets` to narrow a +child; Hermes's existing unsafe-tool block remains enforced. From f60abd6e37115c99a715828bea5d6101fdce8165 Mon Sep 17 00:00:00 2001 From: Tony Simons <asimons81@gmail.com> Date: Wed, 15 Jul 2026 19:53:41 -0500 Subject: [PATCH 44/71] fix subagent lifecycle ownership invariants --- agent/subagent_lifecycle.py | 57 ++- hermes_cli/plugins.py | 8 +- run_agent.py | 4 +- tests/agent/test_subagent_lifecycle.py | 156 +++++++- tools/delegate_tool.py | 336 ++++++++++-------- .../developer-guide/subagent-lifecycle-api.md | 11 +- 6 files changed, 410 insertions(+), 162 deletions(-) diff --git a/agent/subagent_lifecycle.py b/agent/subagent_lifecycle.py index 5b3aa84aea7c..63603675bc74 100644 --- a/agent/subagent_lifecycle.py +++ b/agent/subagent_lifecycle.py @@ -7,14 +7,17 @@ sessions; plugins must obtain it from ``PluginContext.subagent_lifecycle``. from __future__ import annotations +import contextvars import dataclasses import enum import hashlib import hmac import json +import math import secrets import threading import time +from contextlib import contextmanager from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError from typing import Any, Callable, Mapping, Optional @@ -155,6 +158,24 @@ class _Registry: _REGISTRY = _Registry() _EXECUTOR = ThreadPoolExecutor(max_workers=8, thread_name_prefix="hermes-lifecycle") _SECRET = secrets.token_bytes(32) +_ACTIVE_PARENT_AGENT: contextvars.ContextVar[Any] = contextvars.ContextVar( + "hermes_subagent_lifecycle_parent", default=None +) + + +@contextmanager +def bind_subagent_parent(parent_agent: Any): + """Bind the host-owned parent for the current agent turn.""" + token = _ACTIVE_PARENT_AGENT.set(parent_agent) + try: + yield + finally: + _ACTIVE_PARENT_AGENT.reset(token) + + +def get_active_subagent_parent() -> Any: + """Return the parent bound to this execution context, if any.""" + return _ACTIVE_PARENT_AGENT.get() class SubagentLifecycleService: @@ -190,9 +211,12 @@ class SubagentLifecycleService: # Delegate construction remains internal so plugin code never imports # private delegation helpers or manipulates the active-child registry. - from tools.delegate_tool import _build_child_agent, DEFAULT_MAX_ITERATIONS + from tools.delegate_tool import ( + _build_child_preserving_parent_tools, + DEFAULT_MAX_ITERATIONS, + ) - child = _build_child_agent( + child = _build_child_preserving_parent_tools( task_index=0, goal=request.goal, context=request.context, @@ -311,9 +335,31 @@ class SubagentLifecycleService: def _record(self, handle: SubagentHandle) -> Optional[_Record]: if ( not isinstance(handle, SubagentHandle) + or type(handle.contract_version) is not int or handle.contract_version != PUBLIC_CONTRACT_VERSION ): return None + if ( + not isinstance(handle.subagent_id, str) + or not handle.subagent_id + or ( + handle.parent_session_id is not None + and not isinstance(handle.parent_session_id, str) + ) + or ( + handle.correlation_id is not None + and not isinstance(handle.correlation_id, str) + ) + or isinstance(handle.created_at, bool) + or not isinstance(handle.created_at, (int, float)) + or not math.isfinite(handle.created_at) + or (handle.provider is not None and not isinstance(handle.provider, str)) + or (handle.model is not None and not isinstance(handle.model, str)) + or not isinstance(handle.role, str) + or type(handle.depth) is not int + or not isinstance(handle.capability, str) + ): + return None if not hmac.compare_digest( handle.capability, self._capability( @@ -349,13 +395,14 @@ class SubagentLifecycleService: def _run(self, record: _Record, goal: str, parent: Any) -> None: with _REGISTRY.lock: - record.state = SubagentState.RUNNING + if record.state is not SubagentState.CANCEL_REQUESTED: + record.state = SubagentState.RUNNING record.started_at = time.time() record.updated_at = record.started_at try: - from tools.delegate_tool import _run_single_child + from tools.delegate_tool import _run_child_lifecycle - raw = _run_single_child(0, goal, record.agent, parent) + raw = _run_child_lifecycle(0, goal, record.agent, parent) status = ( str(raw.get("status", "error")) if isinstance(raw, dict) else "error" ) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 20eb30359c2b..d706ca1a468b 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -374,10 +374,12 @@ class PluginContext: snapshots; they never receive a live agent or a private registry. """ if self._subagent_lifecycle is None: - from agent.subagent_lifecycle import SubagentLifecycleService + from agent.subagent_lifecycle import ( + SubagentLifecycleService, + get_active_subagent_parent, + ) self._subagent_lifecycle = SubagentLifecycleService( - lambda: getattr(self._manager._cli_ref, "agent", None) - if self._manager._cli_ref is not None else None + get_active_subagent_parent ) return self._subagent_lifecycle diff --git a/run_agent.py b/run_agent.py index 21e550386106..703759d8ea62 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6822,6 +6822,8 @@ class AIAgent: reset_conversation_context, set_conversation_context, ) + from agent.subagent_lifecycle import bind_subagent_parent + # Publish the conversation id for ambient Nous Portal tagging. Every # LLM call made inside this turn — main loop, compression, vision, # web_extract, session_search, MoA slots, background-review forks @@ -6841,7 +6843,7 @@ class AIAgent: # replaces the value with the live runtime after fallback restoration. # Keep the scope local instead of storing ContextVar tokens on the agent, # which may be observed from another thread. - with scoped_runtime_main({}): + with bind_subagent_parent(self), scoped_runtime_main({}): try: return run_conversation( self, diff --git a/tests/agent/test_subagent_lifecycle.py b/tests/agent/test_subagent_lifecycle.py index 409d10faa894..faa3ddabbf55 100644 --- a/tests/agent/test_subagent_lifecycle.py +++ b/tests/agent/test_subagent_lifecycle.py @@ -2,6 +2,7 @@ import time from types import SimpleNamespace +from unittest.mock import Mock import pytest @@ -10,6 +11,8 @@ from agent.subagent_lifecycle import ( SubagentLifecycleError, SubagentLifecycleService, SubagentState, + bind_subagent_parent, + get_active_subagent_parent, ) @@ -68,7 +71,7 @@ def test_launch_wait_result_and_handle_round_trip(lifecycle): def test_duplicate_correlation_and_permission_validation(lifecycle): - lifecycle.launch(SubagentLaunchRequest(goal="x", correlation_id="same")) + handle = lifecycle.launch(SubagentLaunchRequest(goal="x", correlation_id="same")) with pytest.raises(SubagentLifecycleError, match="Duplicate"): lifecycle.launch(SubagentLaunchRequest(goal="x", correlation_id="same")) with pytest.raises(SubagentLifecycleError, match="broaden"): @@ -77,6 +80,7 @@ def test_duplicate_correlation_and_permission_validation(lifecycle): ) with pytest.raises(SubagentLifecycleError, match="working_directory"): lifecycle.launch(SubagentLaunchRequest(goal="x", working_directory="C:/")) + lifecycle.wait(handle, timeout_seconds=1) def test_cancel_is_cooperative_and_forged_handle_is_unknown(lifecycle): @@ -96,3 +100,153 @@ def test_simultaneous_launches_are_distinct_and_reconnect_is_in_process(lifecycl handles = [lifecycle.launch(SubagentLaunchRequest(goal="x")) for _ in range(10)] assert len({h.subagent_id for h in handles}) == 10 assert lifecycle.reconnect(handles[0]).connected + for handle in handles: + lifecycle.wait(handle, timeout_seconds=1) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("capability", []), + ("contract_version", True), + ("subagent_id", None), + ("parent_session_id", []), + ("correlation_id", []), + ("created_at", "yesterday"), + ("provider", []), + ("model", []), + ("role", []), + ("depth", "one"), + ], +) +def test_malformed_deserialized_handle_is_unknown(lifecycle, field, value): + handle = lifecycle.launch(SubagentLaunchRequest(goal="x")) + malformed = handle.from_dict({**handle.to_dict(), field: value}) + + assert lifecycle.status(malformed).state is SubagentState.UNKNOWN + assert lifecycle.result(malformed).error_classification == "UNKNOWN_HANDLE" + lifecycle.wait(handle, timeout_seconds=1) + + +def test_launch_preserves_parent_tool_resolution(monkeypatch): + import model_tools + + parent = SimpleNamespace(session_id="parent-tools", enabled_toolsets=["file"]) + model_tools._last_resolved_tool_names = ["parent_tool"] + + def build(**_kwargs): + model_tools._last_resolved_tool_names = ["child_tool"] + return FakeChild("sa-tools") + + monkeypatch.setattr("tools.delegate_tool._build_child_agent", build) + monkeypatch.setattr( + "tools.delegate_tool._run_single_child", + lambda *_args, **_kwargs: { + "status": "completed", + "summary": "done", + "api_calls": 0, + "duration_seconds": 0, + }, + ) + + service = SubagentLifecycleService(lambda: parent) + handle = service.launch(SubagentLaunchRequest(goal="x")) + + assert model_tools._last_resolved_tool_names == ["parent_tool"] + assert handle.subagent_id == "sa-tools" + service.wait(handle, timeout_seconds=1) + + +def test_public_lifecycle_runs_host_aggregation(monkeypatch): + memory = Mock() + parent = SimpleNamespace( + session_id="parent-aggregate", + enabled_toolsets=["file"], + _memory_manager=memory, + _current_turn_id="turn-1", + session_estimated_cost_usd=1.0, + session_cost_source="none", + session_cost_status="unknown", + ) + child = FakeChild("sa-aggregate") + child.session_id = "child-session" + hook = Mock() + + monkeypatch.setattr("tools.delegate_tool._build_child_agent", lambda **_kwargs: child) + monkeypatch.setattr( + "tools.delegate_tool._run_single_child", + lambda *_args, **_kwargs: { + "task_index": 0, + "status": "completed", + "summary": "aggregated", + "api_calls": 1, + "duration_seconds": 0.25, + "_child_role": "leaf", + "_child_cost_usd": 2.5, + }, + ) + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", hook) + + service = SubagentLifecycleService(lambda: parent) + handle = service.launch(SubagentLaunchRequest(goal="aggregate me")) + assert service.wait(handle, timeout_seconds=1).state is SubagentState.SUCCEEDED + + memory.on_delegation.assert_called_once_with( + task="aggregate me", result="aggregated", child_session_id="child-session" + ) + hook.assert_called_once_with( + "subagent_stop", + parent_session_id="parent-aggregate", + parent_turn_id="turn-1", + child_session_id="child-session", + child_role="leaf", + child_summary="aggregated", + child_status="completed", + duration_ms=250, + ) + assert parent.session_estimated_cost_usd == 3.5 + assert parent.session_cost_source == "subagent" + assert parent.session_cost_status == "estimated" + + +def test_plugin_context_uses_turn_scoped_parent(monkeypatch): + from hermes_cli.plugins import PluginContext, PluginManifest + + parent = SimpleNamespace(session_id="gateway-parent", enabled_toolsets=["file"]) + monkeypatch.setattr( + "tools.delegate_tool._build_child_agent", lambda **_kwargs: FakeChild("sa-gateway") + ) + monkeypatch.setattr( + "tools.delegate_tool._run_single_child", + lambda *_args, **_kwargs: { + "status": "completed", + "summary": "done", + "api_calls": 0, + "duration_seconds": 0, + }, + ) + manager = SimpleNamespace(_cli_ref=None) + ctx = PluginContext(PluginManifest(name="test", source="test"), manager) + + with bind_subagent_parent(parent): + handle = ctx.subagent_lifecycle.launch(SubagentLaunchRequest(goal="x")) + ctx.subagent_lifecycle.wait(handle, timeout_seconds=1) + + assert handle.parent_session_id == "gateway-parent" + + +def test_agent_turn_binds_and_clears_lifecycle_parent(monkeypatch): + from run_agent import AIAgent + + agent = AIAgent.__new__(AIAgent) + observed = [] + + def run_conversation(parent, *_args, **_kwargs): + observed.append(get_active_subagent_parent()) + return {"final_response": "ok"} + + monkeypatch.setattr("agent.conversation_loop.run_conversation", run_conversation) + + assert agent.run_conversation("hello") == {"final_response": "ok"} + assert observed == [agent] + assert get_active_subagent_parent() is None diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index b3019af05032..32726446aab9 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2576,6 +2576,150 @@ def _run_single_child( logger.debug("Failed to close child agent after delegation") +_PARENT_FINALIZATION_LOCK_GUARD = threading.Lock() +_PARENT_FINALIZATION_FALLBACK_LOCK = threading.RLock() +_CHILD_CONSTRUCTION_LOCK = threading.RLock() + + +def _build_child_preserving_parent_tools(**kwargs): + """Build a child without leaking its resolved toolset into the parent.""" + import model_tools + + with _CHILD_CONSTRUCTION_LOCK: + parent_tool_names = list(model_tools._last_resolved_tool_names) + try: + child = _build_child_agent(**kwargs) + finally: + model_tools._last_resolved_tool_names = parent_tool_names + child._delegate_saved_tool_names = parent_tool_names + return child + + +def _parent_finalization_lock(parent_agent) -> threading.RLock: + """Return the per-parent lock that serializes lifecycle side effects.""" + if parent_agent is None: + return _PARENT_FINALIZATION_FALLBACK_LOCK + lock = getattr(parent_agent, "_subagent_finalization_lock", None) + if lock is not None: + return lock + with _PARENT_FINALIZATION_LOCK_GUARD: + lock = getattr(parent_agent, "_subagent_finalization_lock", None) + if lock is None: + lock = threading.RLock() + try: + setattr(parent_agent, "_subagent_finalization_lock", lock) + except Exception: + return _PARENT_FINALIZATION_FALLBACK_LOCK + return lock + + +def _finalize_child_results( + results: List[Dict[str, Any]], + task_list: List[Dict[str, Any]], + children: List[tuple[int, Dict[str, Any], Any]], + parent_agent, +) -> None: + """Apply host-owned summary, memory, hook, and cost contracts once.""" + with _parent_finalization_lock(parent_agent): + _apply_summary_budget(results, parent_agent) + child_by_index = {index: child for index, _task, child in children} + + if parent_agent and getattr(parent_agent, "_memory_manager", None): + for entry in results: + try: + task_index = entry.get("task_index", -1) + task_goal = ( + task_list[task_index]["goal"] + if isinstance(task_index, int) + and 0 <= task_index < len(task_list) + else "" + ) + child = child_by_index.get(task_index) + parent_agent._memory_manager.on_delegation( + task=task_goal, + result=entry.get("summary", "") or "", + child_session_id=getattr(child, "session_id", ""), + ) + except Exception: + pass + + parent_session_id = getattr(parent_agent, "session_id", None) + try: + from hermes_cli.plugins import invoke_hook as invoke_hook + except Exception: + invoke_hook = None + + children_cost_total = 0.0 + for entry in results: + child_role = entry.pop("_child_role", None) + child_cost = entry.pop("_child_cost_usd", 0.0) + try: + if child_cost: + children_cost_total += float(child_cost) + except (TypeError, ValueError): + pass + if invoke_hook is None: + continue + try: + child_index = entry.get("task_index", -1) + child = child_by_index.get(child_index) + invoke_hook( + "subagent_stop", + parent_session_id=parent_session_id, + parent_turn_id=getattr(parent_agent, "_current_turn_id", "") or "", + child_session_id=getattr(child, "session_id", None), + child_role=child_role, + child_summary=entry.get("summary"), + child_status=entry.get("status"), + tool_call_history=_subagent_stop_tool_call_history( + entry.get("tool_trace") + ), + duration_ms=int((entry.get("duration_seconds") or 0) * 1000), + ) + except Exception: + logger.debug("subagent_stop hook invocation failed", exc_info=True) + + if children_cost_total > 0.0: + try: + current = float( + getattr(parent_agent, "session_estimated_cost_usd", 0.0) or 0.0 + ) + parent_agent.session_estimated_cost_usd = current + children_cost_total + if getattr(parent_agent, "session_cost_source", "none") in { + None, + "", + "none", + }: + parent_agent.session_cost_source = "subagent" + if getattr(parent_agent, "session_cost_status", "unknown") in { + None, + "", + "unknown", + }: + parent_agent.session_cost_status = "estimated" + except Exception: + logger.debug("Subagent cost rollup failed", exc_info=True) + + +def _run_child_lifecycle( + task_index: int, + goal: str, + child=None, + parent_agent=None, +) -> Dict[str, Any]: + """Run one child and apply the same host lifecycle used by delegate_task.""" + result = _run_single_child(task_index, goal, child, parent_agent) + result.setdefault("task_index", task_index) + task = {"goal": goal} + _finalize_child_results( + [result], + [{"goal": ""} for _ in range(task_index)] + [task], + [(task_index, task, child)], + parent_agent, + ) + return result + + def _recover_tasks_from_json_string( tasks: Any, ) -> tuple[Optional[List[Dict[str, Any]]], Optional[str]]: @@ -2746,13 +2890,6 @@ def delegate_task( task_list, context ) - # Save parent tool names BEFORE any child construction mutates the global. - # _build_child_agent() calls AIAgent() which calls get_tool_definitions(), - # which overwrites model_tools._last_resolved_tool_names with child's toolset. - import model_tools as _model_tools - - _parent_tool_names = list(_model_tools._last_resolved_tool_names) - # Capture the ORIGINATING session's wake target BEFORE any child agent is # constructed: _build_child_agent() -> AIAgent() -> agent_init calls # set_current_session_id(child.session_id), which clobbers the @@ -2765,53 +2902,49 @@ def delegate_task( _origin_wake_sid = _current_origin_session_id() - # Build all child agents on the main thread (thread-safe construction) - # Wrapped in try/finally so the global is always restored even if a - # child build raises (otherwise _last_resolved_tool_names stays corrupted). + # Build all child agents on the main thread (thread-safe construction). + # _build_child_preserving_parent_tools saves/restores the parent's + # resolved tool names around each construction under a lock, so child + # toolset resolution never leaks into the parent (shared with the plugin + # subagent-lifecycle API). children = [] - try: - for i, t in enumerate(task_list): - # Per-task role beats top-level; normalise again so unknown - # per-task values warn and degrade to leaf uniformly. - effective_role = _normalize_role(t.get("role") or top_role) - child = _build_child_agent( - task_index=i, - goal=t["goal"], - context=t.get("context"), - # Subagents always inherit the parent's toolsets; the model - # cannot choose or narrow them (no model-facing toolsets arg). - toolsets=None, - model=creds["model"], - max_iterations=effective_max_iter, - task_count=n_tasks, - parent_agent=parent_agent, - override_provider=creds["provider"], - override_base_url=creds["base_url"], - override_api_key=creds["api_key"], - override_api_mode=creds["api_mode"], - override_request_overrides=creds.get("request_overrides"), - override_max_tokens=creds.get("max_output_tokens"), - override_acp_command=creds.get("command"), - override_acp_args=creds.get("args"), - role=effective_role, + for i, t in enumerate(task_list): + # Per-task role beats top-level; normalise again so unknown + # per-task values warn and degrade to leaf uniformly. + effective_role = _normalize_role(t.get("role") or top_role) + child = _build_child_preserving_parent_tools( + task_index=i, + goal=t["goal"], + context=t.get("context"), + # Subagents always inherit the parent's toolsets; the model + # cannot choose or narrow them (no model-facing toolsets arg). + toolsets=None, + model=creds["model"], + max_iterations=effective_max_iter, + task_count=n_tasks, + parent_agent=parent_agent, + override_provider=creds["provider"], + override_base_url=creds["base_url"], + override_api_key=creds["api_key"], + override_api_mode=creds["api_mode"], + override_request_overrides=creds.get("request_overrides"), + override_max_tokens=creds.get("max_output_tokens"), + override_acp_command=creds.get("command"), + override_acp_args=creds.get("args"), + role=effective_role, + ) + # Tee the child's progress events into its live transcript log. + # wrap_progress_callback preserves the inner callback contract + # (including the _flush attribute) and never lets writer failures + # reach the agent loop. When no parent display exists the inner + # callback is None and the wrapper still records events. + _writer = live_writers[i] if i < len(live_writers) else None + if _writer is not None: + child.tool_progress_callback = wrap_progress_callback( + getattr(child, "tool_progress_callback", None), _writer ) - # Override with correct parent tool names (before child construction mutated global) - child._delegate_saved_tool_names = _parent_tool_names - # Tee the child's progress events into its live transcript log. - # wrap_progress_callback preserves the inner callback contract - # (including the _flush attribute) and never lets writer failures - # reach the agent loop. When no parent display exists the inner - # callback is None and the wrapper still records events. - _writer = live_writers[i] if i < len(live_writers) else None - if _writer is not None: - child.tool_progress_callback = wrap_progress_callback( - getattr(child, "tool_progress_callback", None), _writer - ) - child._live_transcript_path = str(_writer.path) - children.append((i, t, child)) - finally: - # Authoritative restore: reset global to parent's tool names after all children built - _model_tools._last_resolved_tool_names = _parent_tool_names + child._live_transcript_path = str(_writer.path) + children.append((i, t, child)) def _execute_and_aggregate() -> dict: """Run all built children (1 or N), join on them, aggregate results, @@ -2955,104 +3088,7 @@ def delegate_task( # headroom (split across the batch) before they enter the parent's # conversation. Full text is spilled to disk so nothing is lost. # Covers both the single-task and batch paths. See PR #9126. - _apply_summary_budget(results, parent_agent) - - # Notify parent's memory provider of delegation outcomes - if ( - parent_agent - and hasattr(parent_agent, "_memory_manager") - and parent_agent._memory_manager - ): - for entry in results: - try: - _task_goal = ( - task_list[entry["task_index"]]["goal"] - if entry["task_index"] < len(task_list) - else "" - ) - parent_agent._memory_manager.on_delegation( - task=_task_goal, - result=entry.get("summary", "") or "", - child_session_id=( - getattr(children[entry["task_index"]][2], "session_id", "") - if entry["task_index"] < len(children) - else "" - ), - ) - except Exception: - pass - - # Fire subagent_stop hooks once per child, serialised on the parent thread. - # This keeps Python-plugin and shell-hook callbacks off of the worker threads - # that ran the children, so hook authors don't need to reason about - # concurrent invocation. Role was captured into the entry dict in - # _run_single_child (or the fabricated-entry branches above) before the - # child was closed. - _parent_session_id = getattr(parent_agent, "session_id", None) - try: - from hermes_cli.plugins import invoke_hook as _invoke_hook - except Exception: - _invoke_hook = None - # Aggregate child spend here so the parent's footer/UI reflect the true - # cost of a subagent-heavy turn. Port of Kilo-Org/kilocode#9448. Each - # child's cost was captured in _run_single_child before its AIAgent was - # closed; we fold them into the parent in one pass alongside the - # subagent_stop hook loop so we don't walk `results` twice. - _children_cost_total = 0.0 - for entry in results: - child_role = entry.pop("_child_role", None) - child_cost = entry.pop("_child_cost_usd", 0.0) - try: - if child_cost: - _children_cost_total += float(child_cost) - except (TypeError, ValueError): - pass - if _invoke_hook is None: - continue - try: - _child_index = entry.get("task_index", -1) - _child_agent = ( - children[_child_index][2] - if isinstance(_child_index, int) and 0 <= _child_index < len(children) - else None - ) - _invoke_hook( - "subagent_stop", - parent_session_id=_parent_session_id, - parent_turn_id=getattr(parent_agent, "_current_turn_id", "") or "", - child_session_id=getattr(_child_agent, "session_id", None), - child_role=child_role, - child_summary=entry.get("summary"), - child_status=entry.get("status"), - tool_call_history=_subagent_stop_tool_call_history( - entry.get("tool_trace") - ), - duration_ms=int((entry.get("duration_seconds") or 0) * 1000), - ) - except Exception: - logger.debug("subagent_stop hook invocation failed", exc_info=True) - - # Fold the aggregated child cost into the parent's session total. This is - # additive — each delegate_task call contributes its own children — so - # nested orchestrator→worker trees roll up naturally: each layer's own - # delegate_task() folds its direct children in, and when the orchestrator - # itself finishes, its parent folds the orchestrator's now-inflated total - # on top. Degrades silently if the parent lacks the counter (older test - # fixtures, etc.). - if _children_cost_total > 0.0: - try: - current = float(getattr(parent_agent, "session_estimated_cost_usd", 0.0) or 0.0) - parent_agent.session_estimated_cost_usd = current + _children_cost_total - # Upgrade the cost_source so the UI doesn't label a partially-real - # total as "none" when the parent itself hadn't billed any calls - # yet (rare but possible when the parent's only action this turn - # was delegate_task). - if getattr(parent_agent, "session_cost_source", "none") in {None, "", "none"}: - parent_agent.session_cost_source = "subagent" - if getattr(parent_agent, "session_cost_status", "unknown") in {None, "", "unknown"}: - parent_agent.session_cost_status = "estimated" - except Exception: - logger.debug("Subagent cost rollup failed", exc_info=True) + _finalize_child_results(results, task_list, children, parent_agent) total_duration = round(time.monotonic() - overall_start, 2) diff --git a/website/docs/developer-guide/subagent-lifecycle-api.md b/website/docs/developer-guide/subagent-lifecycle-api.md index 6c2dcac53c53..7054afcdb735 100644 --- a/website/docs/developer-guide/subagent-lifecycle-api.md +++ b/website/docs/developer-guide/subagent-lifecycle-api.md @@ -7,11 +7,15 @@ sidebar_label: Subagent lifecycle API Plugins can launch and supervise fresh Hermes child sessions without importing `tools.delegate_tool`, gateway internals, TUI state, or `AIAgent` fields. +The service resolves its parent from the current agent turn, so it works in +CLI, gateway, non-interactive, and kanban-worker sessions. Launching outside an +active agent turn fails closed with `No active Hermes parent session`. ```python from agent.subagent_lifecycle import SubagentLaunchRequest -def register(ctx): +def launch_review(ctx): + # Call from a plugin tool or hook while an agent turn is active. service = ctx.subagent_lifecycle handle = service.launch(SubagentLaunchRequest( goal="Review this change for regressions.", @@ -39,7 +43,10 @@ claims completion until `wait` or `result` observes a terminal state. Terminal results are immutable, idempotent, bounded to 32k characters, omit transcripts and hidden reasoning, and include a stable result hash. -This API is lifecycle-managed asynchronous execution. It does not change the +This API is lifecycle-managed asynchronous execution. Child construction and +completion use the same host-owned path as `delegate_task`, including parent +tool-resolution restoration, memory notification, serialized `subagent_stop` +hooks, resource cleanup, and child-cost rollup. It does not change the synchronous `delegate_task` tool, batch delegation, or its gateway/TUI display. The initial implementation retains metadata and terminal results in-process for one hour. From 98fe6d0a8e36220801ada098a0598139e7129222 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:43:48 -0700 Subject: [PATCH 45/71] fix(delegation): integrate lifecycle refactor with tool-history + daemon pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the salvaged #63359: - _finalize_child_results carries tool_call_history on subagent_stop (the #62011/#72403 field landed after the PR branched; the shared pipeline must emit it for both delegate_task and plugin-launched children). Lifecycle test updated for the new payload field. - The lifecycle executor uses DaemonThreadPoolExecutor — a wedged or abandoned child must never block interpreter exit at atexit-join time (same rationale as _run_single_child's timeout executor and the async-delegation pool). - delegate_task's batch path keeps live-transcript wiring while routing child construction through the shared _build_child_preserving_parent_tools helper. --- agent/subagent_lifecycle.py | 7 ++++++- tests/agent/test_subagent_lifecycle.py | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/agent/subagent_lifecycle.py b/agent/subagent_lifecycle.py index 63603675bc74..b85d84d9e194 100644 --- a/agent/subagent_lifecycle.py +++ b/agent/subagent_lifecycle.py @@ -156,7 +156,12 @@ class _Registry: _REGISTRY = _Registry() -_EXECUTOR = ThreadPoolExecutor(max_workers=8, thread_name_prefix="hermes-lifecycle") +# Daemon worker pool: a wedged/abandoned child must never block interpreter +# exit at atexit-join time (same rationale as _run_single_child's timeout +# executor and the async-delegation registry pool). +from tools.daemon_pool import DaemonThreadPoolExecutor as _DaemonExecutor + +_EXECUTOR = _DaemonExecutor(max_workers=8, thread_name_prefix="hermes-lifecycle") _SECRET = secrets.token_bytes(32) _ACTIVE_PARENT_AGENT: contextvars.ContextVar[Any] = contextvars.ContextVar( "hermes_subagent_lifecycle_parent", default=None diff --git a/tests/agent/test_subagent_lifecycle.py b/tests/agent/test_subagent_lifecycle.py index faa3ddabbf55..38fd62d93a6a 100644 --- a/tests/agent/test_subagent_lifecycle.py +++ b/tests/agent/test_subagent_lifecycle.py @@ -202,6 +202,10 @@ def test_public_lifecycle_runs_host_aggregation(monkeypatch): child_role="leaf", child_summary="aggregated", child_status="completed", + # Redacted tool history rides the shared finalization pipeline + # (#62011/#72403); empty here because the fabricated result carries + # no tool_trace. + tool_call_history=[], duration_ms=250, ) assert parent.session_estimated_cost_usd == 3.5 From 7f87b672455c79bbbd0360788782d6220b581b43 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Mon, 27 Jul 2026 01:30:29 -0500 Subject: [PATCH 46/71] =?UTF-8?q?Revert=20the=20streaming-backfill=20gate?= =?UTF-8?q?=20=E2=80=94=20it=20broke=20a=20real=20E2E=20invariant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deferring the FIRST_PAINT_BUDGET -> RENDER_BUDGET backfill while a thread streams cut a 1374ms streaming-session switch to instant, but it also means a streaming transcript stays clipped to 60 parts for the duration of the run. `large-session-resume` asserts the resumed transcript shows every seeded reply exactly once, and that count is short while the budget is held down — a genuine behavior change, not a flaky test. The switch cost is real and still worth fixing, but the fix has to keep the full transcript mounted (raise the budget in idle callbacks, or virtualize) rather than withhold it. Session-switch work is happening in a parallel effort; leaving the invariant intact for them. Everything else in this branch is untouched: the reflow-gated RO pins (11.5 -> 59fps drag), the structural/weight signature split, the adaptive stream flush, the tree-split preview, and the tool-row memo boundaries. --- .../src/components/assistant-ui/thread/list.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index f227bce1d833..d3a1936b71c8 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -217,18 +217,8 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({ // changes stay urgent (main.tsx disables router transitions); it's exactly // this backfill that belongs at background priority. "Show earlier" pages // (budget > RENDER_BUDGET) never re-enter here. - // - // NOT while this thread is streaming: an interrupted transition RESTARTS - // from scratch, and stream flushes land every 33-250ms — so switching to a - // streaming session re-ran the whole 300-part backfill render over and - // over (measured: 1374ms settle, 30 commits, Primitive.div x2237 on one - // switch; the same switch while idle settles in ~50ms). The user lands at - // the live tail anyway; older turns backfill the moment the run ends, and - // "Show earlier" remains the manual path meanwhile. - const threadRunning = useAuiState(s => s.thread.isRunning) - useEffect(() => { - if (renderBudget >= RENDER_BUDGET || threadRunning) { + if (renderBudget >= RENDER_BUDGET) { return } @@ -240,7 +230,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({ }) return () => cancelAnimationFrame(rafId) - }, [renderBudget, threadRunning]) + }, [renderBudget]) // Weights (per-message part counts) fold into the BUDGET only. Group // identity stays structural, so a streaming append re-runs this cheap sum — From cff60b205d68acd670b4c320cf39d146a995bf8c Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:12:35 -0600 Subject: [PATCH 47/71] fix(sessions): verify fully reconstructed recovery --- hermes_cli/session_recovery.py | 22 +++++++- tests/hermes_cli/test_session_recovery.py | 67 +++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/hermes_cli/session_recovery.py b/hermes_cli/session_recovery.py index f321b8403700..c75f8552fd0d 100644 --- a/hermes_cli/session_recovery.py +++ b/hermes_cli/session_recovery.py @@ -1094,13 +1094,33 @@ def _verify_recovered_database( else: verification["errors"].append(message) + cleanup = orphan_cleanup or {} + rebuilt_sessions = int(cleanup.get("sessions_reconstructed") or 0) + retained_messages = int(cleanup.get("messages_retained") or 0) + removed_messages = int(cleanup.get("messages_removed") or 0) + # A wholly unreadable sessions b-tree is recoverable when every output + # parent was rebuilt from the surviving messages and none were dropped. + # This is still data loss, but it is not structural verification failure. + sessions_fully_reconstructed = bool( + rebuilt_sessions > 0 + and counts.get("sessions") == rebuilt_sessions + and counts.get("messages") == retained_messages + and removed_messages == 0 + ) + for table, table_report in copy_report.items(): status = table_report.get("status") if status not in {"failed", "partial"}: continue message = f"{table} copy status is {status}" if allow_partial and ( - status == "partial" or table not in {"sessions", "messages"} + status == "partial" + or table not in {"sessions", "messages"} + or ( + table == "sessions" + and status == "failed" + and sessions_fully_reconstructed + ) ): verification["warnings"].append(message) verification["loss_detected"] = True diff --git a/tests/hermes_cli/test_session_recovery.py b/tests/hermes_cli/test_session_recovery.py index b477395a56ea..4ecacfa67f25 100644 --- a/tests/hermes_cli/test_session_recovery.py +++ b/tests/hermes_cli/test_session_recovery.py @@ -288,6 +288,20 @@ def _corrupt_middle_table_leaf( return leaf_page +def _corrupt_table_root(path: Path, root_page: int) -> None: + data = bytearray(path.read_bytes()) + page_size = int.from_bytes(data[16:18], "big") + if page_size == 1: + page_size = 65_536 + page_start = (root_page - 1) * page_size + header_offset = page_start + (100 if root_page == 1 else 0) + assert data[header_offset] in {0x02, 0x05, 0x0A, 0x0D} + # Damage the root enough that no rowid bounds can be read. This reproduces + # a fully failed sessions copy while leaving the messages b-tree intact. + data[header_offset + 3 : header_offset + 5] = b"\xff\xff" + path.write_bytes(data) + + def test_snapshot_blocks_connections_opened_during_the_copy( tmp_path: Path, ) -> None: @@ -999,6 +1013,59 @@ def test_partial_recovery_reconstructs_unreadable_sessions_without_message_loss( conn.close() +def test_partial_recovery_verifies_fully_reconstructed_sessions( + tmp_path: Path, +) -> None: + """A failed sessions copy is recoverable when every parent is rebuilt. + + Reported July 2026: all 194 original session rows were unreadable, but + partial recovery reconstructed 194 placeholders, retained 20,817 readable + messages, removed none, and produced clean integrity, FK, and FTS checks. + The verifier still treated ``sessions copy status is failed`` as fatal and + printed "Do not install it" for a structurally healthy partial output. + """ + source = tmp_path / "sessions-root-destroyed.db" + output = tmp_path / "sessions-root-destroyed-recovered.db" + session_count = 60 + sessions_root = _make_many_sessions_source(source, session_count) + _corrupt_table_root(source, sessions_root) + source_hash = _sha256(source) + + report = recover_session_database( + source, + output, + work_dir=tmp_path, + chunk_size=8, + allow_partial=True, + ) + + assert report["copy"]["sessions"]["status"] == "failed" + assert report["copy"]["messages"]["status"] == "complete" + cleanup = report["orphan_cleanup"] + assert cleanup["sessions_reconstructed"] == session_count + assert cleanup["messages_retained"] == session_count + assert cleanup["messages_removed"] == 0 + + verification = report["verification"] + assert verification["errors"] == [] + assert "sessions copy status is failed" in verification["warnings"] + assert verification["integrity_check"] == ["ok"] + assert verification["foreign_key_check"] == [] + assert verification["table_counts"]["sessions"] == session_count + assert verification["table_counts"]["messages"] == session_count + assert report["verified"] is True + assert report["complete"] is False + assert report["partial"] is True + assert report["source_unchanged"] is True + assert _sha256(source) == source_hash + + with sqlite3.connect(str(output)) as conn: + recovered = conn.execute( + "SELECT source, COUNT(*) FROM sessions GROUP BY source" + ).fetchall() + assert recovered == [("recovered", session_count)] + + def test_cli_recover_writes_verified_report_without_touching_source( tmp_path: Path, ) -> None: From 76e416052c88feb87a4660f0a4e9c5aeec034ccf Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:35:13 -0600 Subject: [PATCH 48/71] test(desktop): wait for committed compress directive --- ...session-compression-and-queue-stop.spec.ts | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts index 911f15c78407..f9d8218aba2b 100644 --- a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts +++ b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts @@ -4,11 +4,7 @@ import { expect, test, type Page } from '@playwright/test' -import { - type MockBackendFixture, - setupMockBackend, - waitForAppReady, -} from './fixtures' +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' import { MOCK_REPLY, receivedUserTexts, restartMockServer } from './mock-server' async function send(page: Page, text: string, delay = 15): Promise<void> { @@ -25,12 +21,11 @@ async function pasteAndSend(page: Page, text: string): Promise<void> { await page.keyboard.press('Enter') } - async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise<void> { await page.waitForFunction( expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false, text, - { timeout }, + { timeout } ) } @@ -70,15 +65,18 @@ test.describe('session compression', () => { const composer = page.locator('[contenteditable="true"]').first() await composer.click() await composer.type('/compress', { delay: 15 }) - await page.getByText('/compress').first().waitFor({ state: 'visible' }) + const compressionCompletion = page + .locator('[data-slot="composer-completion-drawer"][role="listbox"]') + .getByRole('button', { name: /^\/compress\b/ }) + await expect(compressionCompletion).toBeVisible() + await compressionCompletion.hover() + await expect(compressionCompletion).toHaveAttribute('data-highlighted', '') await page.keyboard.press('Enter') + await expect(composer.locator('[data-slot="aui_slash-chip"][title="/compress"]')).toBeVisible() await composer.type(' preserve the three test turns', { delay: 15 }) await page.keyboard.press('Enter') await expect - .poll( - () => page.locator('[data-slot="aui_thread-viewport"]').textContent(), - { timeout: 90_000 }, - ) + .poll(() => page.locator('[data-slot="aui_thread-viewport"]').textContent(), { timeout: 90_000 }) .toMatch(/Compressed|No changes from compression/) // Compression rotates the agent's live session id. A post-compression @@ -105,7 +103,7 @@ auxiliary: provider: custom model: mock-model`, mockServer: { - holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.', + holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.' } }) await waitForAppReady(fixture, 120_000) From b1081c1d22c2f10d0c1f9e1d76e340920228866b Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:50:01 -0600 Subject: [PATCH 49/71] test(desktop): assert compress argument stage --- .../e2e/session-compression-and-queue-stop.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts index f9d8218aba2b..e529cb96188e 100644 --- a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts +++ b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts @@ -59,9 +59,10 @@ test.describe('session compression', () => { // Commit the command before typing its argument. This waits for the async // completion request on cold CI workers, then uses the composer's own - // keyboard accept path to replace the `/compress` trigger with a command - // chip. Clicking a later completion after typing the argument can insert a - // second command token (for example `//compress ...`) as plain text. + // keyboard accept path to advance the arg-taking command. Bare commands + // that take arguments intentionally remain plain text instead of becoming + // chips. Clicking a later completion after typing the argument can insert + // a second command token (for example `//compress ...`) as plain text. const composer = page.locator('[contenteditable="true"]').first() await composer.click() await composer.type('/compress', { delay: 15 }) @@ -72,7 +73,7 @@ test.describe('session compression', () => { await compressionCompletion.hover() await expect(compressionCompletion).toHaveAttribute('data-highlighted', '') await page.keyboard.press('Enter') - await expect(composer.locator('[data-slot="aui_slash-chip"][title="/compress"]')).toBeVisible() + await expect.poll(() => composer.textContent()).toMatch(/^\/compress\s*$/) await composer.type(' preserve the three test turns', { delay: 15 }) await page.keyboard.press('Enter') await expect From c7b75a7849cb260e6f17a045473bfdd0ea21ca81 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:03:20 -0600 Subject: [PATCH 50/71] test(desktop): isolate compression from slash completion --- ...session-compression-and-queue-stop.spec.ts | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts index e529cb96188e..e48c52cda2b0 100644 --- a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts +++ b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts @@ -57,25 +57,14 @@ test.describe('session compression', () => { await send(page, 'E2E_COMPRESSION_THIRD') await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_THIRD').length).toBe(1) - // Commit the command before typing its argument. This waits for the async - // completion request on cold CI workers, then uses the composer's own - // keyboard accept path to advance the arg-taking command. Bare commands - // that take arguments intentionally remain plain text instead of becoming - // chips. Clicking a later completion after typing the argument can insert - // a second command token (for example `//compress ...`) as plain text. + // This test covers compression and continuation, not slash completion. + // Insert the complete command atomically and click Send so an async + // completion response cannot consume Enter as a picker acceptance. const composer = page.locator('[contenteditable="true"]').first() await composer.click() - await composer.type('/compress', { delay: 15 }) - const compressionCompletion = page - .locator('[data-slot="composer-completion-drawer"][role="listbox"]') - .getByRole('button', { name: /^\/compress\b/ }) - await expect(compressionCompletion).toBeVisible() - await compressionCompletion.hover() - await expect(compressionCompletion).toHaveAttribute('data-highlighted', '') - await page.keyboard.press('Enter') - await expect.poll(() => composer.textContent()).toMatch(/^\/compress\s*$/) - await composer.type(' preserve the three test turns', { delay: 15 }) - await page.keyboard.press('Enter') + await page.keyboard.insertText('/compress preserve the three test turns') + await expect.poll(() => composer.textContent()).toContain('preserve the three test turns') + await page.getByRole('button', { name: 'Send', exact: true }).click() await expect .poll(() => page.locator('[data-slot="aui_thread-viewport"]').textContent(), { timeout: 90_000 }) .toMatch(/Compressed|No changes from compression/) From 9c28771cf3121b2e03fe752737ffe638a0173c6b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Mon, 27 Jul 2026 01:38:21 -0500 Subject: [PATCH 51/71] fix(desktop): keep pinned sidebar rows in user order flattenSessionsWithBranches always re-sorted roots by last_active, so a turn finishing floated background tasks over the hand-picked Pinned list even though $pinnedSessionIds already stored drag order. preserveOrder skips that sort for pins (and other non-date-grouped manual lists); default recents stay recency-sorted for truthful date buckets. --- .../src/app/chat/sidebar/sessions-section.tsx | 10 ++++++- .../src/lib/session-branch-tree.test.ts | 30 +++++++++++++++++++ apps/desktop/src/lib/session-branch-tree.ts | 26 +++++++++++++--- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx index d3a65e1d787b..4d7578bde94e 100644 --- a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -205,7 +205,15 @@ export function SidebarSessionsSection({ // The flat recents/pinned list is the only place sessions reorder by hand; // grouped/tree views always sort by creation date and never drag. const sessionsDraggable = sortable && !!onReorderSessions - const displayEntries = useMemo(() => flattenSessionsWithBranches(sessions), [sessions]) + // Pinned and manual-drag lists pass sessions already in the caller's order. + // Default recents stay dateGrouped and still re-sort roots by group recency + // so partition buckets stay truthful — but NEVER for pins, where a turn + // finishing was floating background tasks over the user's fixed ranking. + const preserveInputOrder = pinned || (sessionsDraggable && !dateGrouped) + const displayEntries = useMemo( + () => flattenSessionsWithBranches(sessions, { preserveOrder: preserveInputOrder }), + [sessions, preserveInputOrder] + ) const renderRow = (session: SessionInfo, draggable: boolean, branchStem?: string) => { const rowProps = { diff --git a/apps/desktop/src/lib/session-branch-tree.test.ts b/apps/desktop/src/lib/session-branch-tree.test.ts index 133fa324cb40..acdc0a3abf93 100644 --- a/apps/desktop/src/lib/session-branch-tree.test.ts +++ b/apps/desktop/src/lib/session-branch-tree.test.ts @@ -50,4 +50,34 @@ describe('flattenSessionsWithBranches', () => { expect(flattenSessionsWithBranches([branch])).toEqual([{ session: branch }]) }) + + it('re-sorts roots by group recency by default (pinned-style jumps without preserveOrder)', () => { + // Stale important chat first in the caller's array; a recently-active + // background task second. Default path must lift the fresher root — that + // is what was scrambling the Pinned section before preserveOrder. + const important = session('important', { last_active: 10 }) + const background = session('background', { last_active: 99 }) + + expect(flattenSessionsWithBranches([important, background]).map(e => e.session.id)).toEqual([ + 'background', + 'important' + ]) + }) + + it("preserveOrder keeps the caller's root order even when activity is newer lower down", () => { + const important = session('important', { last_active: 10 }) + const background = session('background', { last_active: 99 }) + const branch = session('branch', { last_active: 50, parent_session_id: 'important' }) + + expect( + flattenSessionsWithBranches([important, background, branch], { preserveOrder: true }).map(e => ({ + id: e.session.id, + stem: e.branchStem + })) + ).toEqual([ + { id: 'important', stem: undefined }, + { id: 'branch', stem: '└─ ' }, + { id: 'background', stem: undefined } + ]) + }) }) diff --git a/apps/desktop/src/lib/session-branch-tree.ts b/apps/desktop/src/lib/session-branch-tree.ts index f99360d1c5ed..c415d873e2f1 100644 --- a/apps/desktop/src/lib/session-branch-tree.ts +++ b/apps/desktop/src/lib/session-branch-tree.ts @@ -5,10 +5,23 @@ export interface SidebarSessionEntry { session: SessionInfo } +export interface FlattenSessionsOptions { + /** + * Keep the input root order instead of re-sorting by group recency. + * Use for hand-ordered surfaces (pinned ids, manual recents drag) so a + * turn completing can't float a row. Branch children still nest under + * their parent; sibling branches stay ordered by their own recency. + */ + preserveOrder?: boolean +} + const recency = (session: SessionInfo): number => session.last_active || session.started_at || 0 /** Flat list with branch/fork sessions nested visually under their parent. */ -export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): SidebarSessionEntry[] { +export function flattenSessionsWithBranches( + sessions: readonly SessionInfo[], + options: FlattenSessionsOptions = {} +): SidebarSessionEntry[] { if (sessions.length < 2) { return sessions.map(session => ({ session })) } @@ -53,6 +66,7 @@ export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): S // A group sorts by its freshest member, so activity on any branch lifts the // whole parent→branches cluster together instead of stranding the parent at // its own stale timestamp. Memoized — each subtree is folded at most once. + // Skipped when preserveOrder is set: the caller already chose positions. const groupRecencyMemo = new Map<string, number>() const groupRecency = (session: SessionInfo): number => { @@ -92,11 +106,15 @@ export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): S children?.forEach((child, index) => emit(child, index === children.length - 1 ? '└─ ' : '├─ ')) } - sessions + const roots = sessions .filter(session => !nestedIds.has(session.id)) .map((session, index) => ({ index, session })) - .sort((a, b) => groupRecency(b.session) - groupRecency(a.session) || a.index - b.index) - .forEach(({ session }) => emit(session)) + + if (!options.preserveOrder) { + roots.sort((a, b) => groupRecency(b.session) - groupRecency(a.session) || a.index - b.index) + } + + roots.forEach(({ session }) => emit(session)) for (const session of sessions) { if (!seen.has(session.id)) { From 39b5965569a4ef0adf05a38893f7669cd74598f9 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:21:26 -0700 Subject: [PATCH 52/71] refactor(fallback): single owner for backend identity and failure-scoped skips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every fallback/dedup/skip decision asks one question — 'is this candidate the same backend as the one that failed, along the axis that failure invalidated?' — but it was re-implemented inline at six sites across four subsystems, each comparing whatever string was locally convenient. Each incident fixed one site while the others kept the bug: #22548, #70893, #59561, #72468, #62984/#54250/#57584. agent/backend_identity.py now owns the concept: BackendIdentity (provider / model / base_url axes), FailureScope (MODEL / CREDENTIAL / ENDPOINT — each failure class invalidates a different axis), and should_skip_candidate(). Unknown axes never manufacture a skip (over-skipping strands failover; a wrong try costs one RTT). Migrated sites: - chat_completion_helpers.try_activate_fallback: replaces the provider+model early-exit (the #62984 bug: ignored base_url, stranding multi-endpoint pools) AND _fallback_entry_is_same_backend_by_base_url (deleted) - auxiliary_client._try_configured_fallback_chain + _try_main_agent_model_fallback: replace label/model comparisons; auth and payment map to CREDENTIAL scope, keeping the #59561 carve-out - hermes_cli/fallback_cmd add: primary-match + duplicate checks now identity- aware (#54250/#57584): same provider+model on a different explicit base_url is a pool entry, not a duplicate _mark_provider_unhealthy stays label-keyed deliberately: its only triggers are confirmed 402s, which ARE credential-scoped. Owner-level tests pin each incident's semantics by number; sabotage-verified (removing the base_url axis fails the #62984 test). --- agent/auxiliary_client.py | 44 +++++- agent/backend_identity.py | 204 +++++++++++++++++++++++++++ agent/chat_completion_helpers.py | 86 +++-------- hermes_cli/fallback_cmd.py | 35 ++++- tests/agent/test_backend_identity.py | 154 ++++++++++++++++++++ 5 files changed, 446 insertions(+), 77 deletions(-) create mode 100644 agent/backend_identity.py create mode 100644 tests/agent/test_backend_identity.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 910130daa3a0..0165987d85fa 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -4230,10 +4230,20 @@ def _try_main_agent_model_fallback( if not main_provider or not main_model or main_provider.lower() in {"auto", ""}: return None, None, "" - skip = (failed_provider or "").lower().strip() + # Identity + scope semantics owned by agent.backend_identity (#72468): + # model-scoped failures skip only the exact deployment that failed; + # provider-wide failures (no failed_model) skip the credential surface. + from agent.backend_identity import ( + BackendIdentity, + FailureScope, + should_skip_candidate, + ) + skip_model = (failed_model or "").strip().lower() or None - if main_provider.lower() == skip and ( - skip_model is None or main_model.lower() == skip_model + if should_skip_candidate( + BackendIdentity.build(provider=main_provider, model=main_model), + BackendIdentity.build(provider=failed_provider, model=skip_model), + FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL, ): # The thing that failed IS the main model (or the failure was # provider-wide) — nothing to fall back to. @@ -4384,8 +4394,24 @@ def _try_configured_fallback_chain( if not chain or not isinstance(chain, list): return None, None, "" - skip = failed_provider.lower().strip() skip_model = (failed_model or "").strip().lower() or None + # Identity + scope semantics owned by agent.backend_identity (#59561, + # #72468): a failed_model means the failure was model-scoped (timeout / + # connection / rate limit) — only the exact deployment is skipped; no + # failed_model means provider-wide (auth/payment) — the whole credential + # surface is skipped. + from agent.backend_identity import ( + BackendIdentity, + FailureScope, + should_skip_candidate, + ) + + failed_ident = BackendIdentity.build( + provider=failed_provider, model=skip_model, + ) + failure_scope = ( + FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL + ) tried = [] min_ctx = _task_minimum_context_length(task) @@ -4396,8 +4422,14 @@ def _try_configured_fallback_chain( if not fb_provider: continue fb_model_raw = str(entry.get("model", "")).strip() - if fb_provider.lower() == skip and ( - skip_model is None or fb_model_raw.lower() == skip_model + if should_skip_candidate( + BackendIdentity.build( + provider=fb_provider, + model=fb_model_raw, + base_url=str(entry.get("base_url") or ""), + ), + failed_ident, + failure_scope, ): continue fb_model = fb_model_raw or None diff --git a/agent/backend_identity.py b/agent/backend_identity.py new file mode 100644 index 000000000000..7a7e9efb6bfe --- /dev/null +++ b/agent/backend_identity.py @@ -0,0 +1,204 @@ +"""Single owner for backend identity and failure-scoped skip decisions. + +Every fallback / dedup / skip / quarantine decision in Hermes ultimately asks +one question: **"is this candidate the same backend as the one that failed, +along the axis that failure invalidated?"** Before this module, that +question was re-implemented inline at six call sites across four subsystems, +each comparing whatever string was locally convenient (provider label, +provider+model, base_url+model, ...). Each incident fixed one site while the +others kept the bug: #22548 (same-shim aliases), #70893 (xai-oauth vs xai — +same host, distinct credential), #59561 (aux chain skipped sibling models), +#72468 (aux main-model safety net, same bug three weeks later), #62984 / +#54250 / #57584 (dedup ignoring base_url strands multi-endpoint pools). + +The root insight: "provider" conflates three independent identity axes, and +each failure class invalidates a different one: + +* **credential surface** — auth 401 / payment 402 kill everything sharing the + credential (every model, every host reached with that key/token). +* **endpoint** — DNS failure / connection refused kill everything behind the + URL, regardless of model or credential. +* **model deployment** — timeout / overload / rate limit / model-incompatible + kill ONE model's deployment. A sibling model behind the same URL is an + independent deployment (real incident: aux ``glm-5.2`` hung and timed out + while main ``macaron-v1-venti`` on the identical endpoint was serving + 448K-token turns). + +Call sites should build :class:`BackendIdentity` values, classify the failure +with :func:`classify_failure_scope`, and ask :func:`should_skip_candidate`. +Do not re-implement any comparison inline — extend THIS module instead. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +logger = logging.getLogger(__name__) + + +class FailureScope(Enum): + """Which identity axis a failure invalidates.""" + + #: Timeout, overload/429, connection blip, model-incompatible, invalid + #: response: evidence against ONE model deployment only. + MODEL = "model" + #: Auth 401 / payment 402: evidence against the shared credential — + #: every model reached with it is equally dead. + CREDENTIAL = "credential" + #: DNS / connection-refused / unreachable host: evidence against the + #: endpoint — every model behind the URL is equally dead. + ENDPOINT = "endpoint" + + +#: Reason strings already used by auxiliary_client's except-chain, mapped to +#: scopes. Unknown reasons default to MODEL — the least-invalidating scope — +#: so an unrecognized failure never over-skips viable candidates. +_REASON_SCOPES = { + "auth error": FailureScope.CREDENTIAL, + "payment error": FailureScope.CREDENTIAL, + "rate limit": FailureScope.MODEL, + "model incompatible with route": FailureScope.MODEL, + "invalid provider response": FailureScope.MODEL, + "connection error": FailureScope.MODEL, + "timeout": FailureScope.MODEL, +} + + +def classify_failure_scope(reason: Optional[str]) -> FailureScope: + """Map a human-readable failure reason to the identity axis it kills.""" + return _REASON_SCOPES.get((reason or "").strip().lower(), FailureScope.MODEL) + + +def _norm_provider(value: Optional[str]) -> str: + return (value or "").strip().lower() + + +def _norm_model(value: Optional[str]) -> str: + return (value or "").strip().lower() + + +def _norm_base_url(value: Optional[str]) -> str: + return (value or "").strip().rstrip("/").lower() + + +@dataclass(frozen=True) +class BackendIdentity: + """Normalized identity of one (provider, model, endpoint) deployment. + + Empty fields mean "unknown" — comparisons treat an unknown axis as + non-distinguishing (it can neither prove sameness nor difference on its + own; the remaining axes decide). + """ + + provider: str = "" + model: str = "" + base_url: str = "" + + @classmethod + def build( + cls, + provider: Optional[str] = None, + model: Optional[str] = None, + base_url: Optional[str] = None, + ) -> "BackendIdentity": + return cls( + provider=_norm_provider(provider), + model=_norm_model(model), + base_url=_norm_base_url(base_url), + ) + + +def _both_first_class(a: BackendIdentity, b: BackendIdentity) -> bool: + """True when both providers are distinct registered first-class providers. + + Two different registry providers have distinct credential surfaces even + when they share an inference host (xai-oauth vs xai, openai-codex vs + openai-api) — #70893. Custom/shim aliases are NOT in the registry, so + two aliases pointing at one URL still count as the same backend (#22548). + """ + if not a.provider or not b.provider or a.provider == b.provider: + return False + try: + from hermes_cli.auth import PROVIDER_REGISTRY + + return a.provider in PROVIDER_REGISTRY and b.provider in PROVIDER_REGISTRY + except Exception: + return False + + +def same_credential_surface(a: BackendIdentity, b: BackendIdentity) -> bool: + """Do two identities share the credential a 401/402 just invalidated? + + Conservative on purpose: an unprovable axis must answer "different" + (try the candidate — worst case one wasted RTT) rather than "same" + (skip — worst case stranded failover). Two distinct custom labels at + one URL may carry different per-entry api_keys, so a shared URL alone + never proves a shared credential; it is only used as a weak signal + when a provider label is missing entirely. + """ + if a.provider and b.provider: + # Same label = same configured credential. Different labels = + # different credential config (first-class registry providers + # explicitly so — #70893; custom entries can each carry their own + # api_key, so sameness is unprovable and we must not skip). + return a.provider == b.provider + # Provider unknown on a side: same explicit URL is the best signal left. + return bool(a.base_url and a.base_url == b.base_url) + + +def same_endpoint(a: BackendIdentity, b: BackendIdentity) -> bool: + """Do two identities sit behind the endpoint that just went unreachable?""" + if a.base_url and b.base_url: + return a.base_url == b.base_url + # An unknown base_url inherits the provider default → same provider + # label implies the same default endpoint. + return bool(a.provider and a.provider == b.provider) + + +def same_deployment(a: BackendIdentity, b: BackendIdentity) -> bool: + """Are these the exact same model deployment (the thing a timeout kills)? + + Provider+model must match; the base_url axis distinguishes only when BOTH + sides carry an explicit URL (#62984: same provider+model on two different + explicit URLs is two deployments — a pool). A side with an unknown URL + inherits the provider default and cannot prove difference. + """ + if not (a.provider and b.provider and a.provider == b.provider): + # Same-host different-label shims: same URL + same model IS the same + # deployment even when the alias labels differ (#22548) — unless both + # labels are first-class registry providers (#70893). + if ( + a.base_url + and a.base_url == b.base_url + and a.model + and a.model == b.model + and not _both_first_class(a, b) + ): + return True + return False + if not (a.model and b.model and a.model == b.model): + return False + if a.base_url and b.base_url and a.base_url != b.base_url: + return False # distinct explicit endpoints — a pool, not a dup + return True + + +def should_skip_candidate( + candidate: BackendIdentity, + failed: BackendIdentity, + scope: FailureScope = FailureScope.MODEL, +) -> bool: + """THE skip predicate: would trying ``candidate`` just repeat the failure? + + True when the candidate is the same backend as ``failed`` along the axis + ``scope`` says the failure invalidated. Every fallback/dedup/skip site + must call this instead of comparing labels inline. + """ + if scope is FailureScope.CREDENTIAL: + return same_credential_surface(candidate, failed) + if scope is FailureScope.ENDPOINT: + return same_endpoint(candidate, failed) + return same_deployment(candidate, failed) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index dcf88e7fd0d9..65415d4cb589 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1544,45 +1544,6 @@ def _fallback_entry_key(fb: dict) -> tuple[str, str, str]: ) -def _fallback_entry_is_same_backend_by_base_url( - *, - current_provider: str, - fb_provider: str, - current_base_url: str, - fb_base_url: str, - current_model: str, - fb_model: str, -) -> bool: - """True when base_url+model identity means the fallback is the same backend. - - Issue #22548: two ``custom_providers`` aliases that point at the same shim - URL with the same model must be skipped, or failover loops on the dead - backend. First-class providers that share a host while using different - auth (``xai-oauth`` vs ``xai``, ``openai-codex`` vs ``openai-api``) are - distinct credential surfaces — skipping them strands configured failover - when primary and fallback reuse the same model slug on that host. - """ - if not ( - fb_base_url - and current_base_url - and fb_base_url == current_base_url - and fb_model == current_model - ): - return False - if fb_provider == current_provider: - return True - try: - from hermes_cli.auth import PROVIDER_REGISTRY - - # Both sides are registered first-class providers → different auth - # identities even when the inference host matches. Allow failover. - if current_provider in PROVIDER_REGISTRY and fb_provider in PROVIDER_REGISTRY: - return False - except Exception: - pass - return True - - def _fallback_entry_unavailable_without_network(agent, fb: dict) -> Optional[str]: """Return a skip reason for fallback entries known to be unusable locally.""" fb_provider = (fb.get("provider") or "").strip().lower() @@ -1668,33 +1629,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool ) return agent._try_activate_fallback(reason) - # Skip entries that resolve to the current (provider, model) — falling - # back to the same backend that just failed loops the failure. Compare - # base_url too so two distinct custom_providers entries pointing at the - # same shim/proxy URL also dedup. See issue #22548. Do NOT treat - # first-class providers that share a host (xai-oauth vs xai) as the same - # backend — they use different credentials. - current_provider = (getattr(agent, "provider", "") or "").strip().lower() - current_model = (getattr(agent, "model", "") or "").strip() - current_base_url = str(getattr(agent, "base_url", "") or "").rstrip("/").lower() - fb_base_url_for_dedup = (fb.get("base_url") or "").strip().rstrip("/").lower() - if fb_provider == current_provider and fb_model == current_model: + # Skip entries that resolve to the same backend that just failed — + # falling back to it loops the failure. Identity semantics (which axes + # distinguish two backends, shim aliases, first-class credential + # surfaces, multi-endpoint pools) are owned by agent.backend_identity — + # see #22548, #70893, #62984. Do not re-implement comparisons here. + from agent.backend_identity import BackendIdentity, should_skip_candidate + + current_ident = BackendIdentity.build( + provider=getattr(agent, "provider", ""), + model=getattr(agent, "model", ""), + base_url=str(getattr(agent, "base_url", "") or ""), + ) + fb_ident = BackendIdentity.build( + provider=fb_provider, + model=fb_model, + base_url=(fb.get("base_url") or ""), + ) + if should_skip_candidate(fb_ident, current_ident): logger.warning( - "Fallback skip: chain entry %s/%s matches current provider/model", - fb_provider, fb_model, - ) - return agent._try_activate_fallback(reason) - if _fallback_entry_is_same_backend_by_base_url( - current_provider=current_provider, - fb_provider=fb_provider, - current_base_url=current_base_url, - fb_base_url=fb_base_url_for_dedup, - current_model=current_model, - fb_model=fb_model, - ): - logger.warning( - "Fallback skip: chain entry base_url %s matches current backend", - fb_base_url_for_dedup, + "Fallback skip: chain entry %s/%s resolves to the same backend " + "as the current one (%s)", + fb_provider, fb_model, current_ident.base_url or current_ident.provider, ) return agent._try_activate_fallback(reason) diff --git a/hermes_cli/fallback_cmd.py b/hermes_cli/fallback_cmd.py index 09142ea99eaf..56c9021ccee3 100644 --- a/hermes_cli/fallback_cmd.py +++ b/hermes_cli/fallback_cmd.py @@ -184,10 +184,26 @@ def cmd_fallback_add(args) -> None: return # Picker picked the same thing that's already the primary → nothing changed, - # and there's nothing useful to add as a fallback to itself. + # and there's nothing useful to add as a fallback to itself. Identity + # semantics owned by agent.backend_identity (#54250/#57584/#62984): same + # provider+model on a DIFFERENT explicit base_url is a different backend + # (multi-endpoint pool) and is a legitimate fallback. + from agent.backend_identity import BackendIdentity, same_deployment + + new_ident = BackendIdentity.build( + provider=new_entry.get("provider"), + model=new_entry.get("model"), + base_url=new_entry.get("base_url"), + ) primary_entry = _extract_fallback_from_model_cfg(model_before) - if primary_entry and primary_entry["provider"] == new_entry["provider"] \ - and primary_entry["model"] == new_entry["model"]: + if primary_entry and same_deployment( + BackendIdentity.build( + provider=primary_entry.get("provider"), + model=primary_entry.get("model"), + base_url=primary_entry.get("base_url"), + ), + new_ident, + ): _restore_model_cfg(model_before) _restore_auth_active_provider(active_provider_before) print() @@ -205,10 +221,17 @@ def cmd_fallback_add(args) -> None: final_cfg = load_config() chain = _read_chain(final_cfg) - # Reject exact-duplicate fallback entries. + # Reject exact-duplicate fallback entries (same deployment; a different + # explicit base_url is a different endpoint and NOT a duplicate). for existing in chain: - if existing.get("provider") == new_entry["provider"] \ - and existing.get("model") == new_entry["model"]: + if same_deployment( + BackendIdentity.build( + provider=existing.get("provider"), + model=existing.get("model"), + base_url=existing.get("base_url"), + ), + new_ident, + ): print() print(f" {_format_entry(new_entry)} is already in the fallback chain — skipped.") return diff --git a/tests/agent/test_backend_identity.py b/tests/agent/test_backend_identity.py new file mode 100644 index 000000000000..6038f9cdabe1 --- /dev/null +++ b/tests/agent/test_backend_identity.py @@ -0,0 +1,154 @@ +"""Owner-level tests for agent.backend_identity. + +This module is the single owner of the "same backend?" question — tests +live HERE, against the predicate, not against each call site (see +references/never-patch-predicates.md in hermes-agent-dev). Each test names +the incident whose semantics it pins. +""" + +from unittest.mock import patch + +from agent.backend_identity import ( + BackendIdentity, + FailureScope, + classify_failure_scope, + same_credential_surface, + same_deployment, + same_endpoint, + should_skip_candidate, +) + + +def _id(provider="", model="", base_url=""): + return BackendIdentity.build(provider=provider, model=model, base_url=base_url) + + +class TestClassifyFailureScope: + def test_auth_and_payment_are_credential_scoped(self): + assert classify_failure_scope("auth error") is FailureScope.CREDENTIAL + assert classify_failure_scope("payment error") is FailureScope.CREDENTIAL + + def test_model_scoped_reasons(self): + for reason in ( + "rate limit", + "timeout", + "connection error", + "model incompatible with route", + "invalid provider response", + ): + assert classify_failure_scope(reason) is FailureScope.MODEL, reason + + def test_unknown_reason_defaults_to_least_invalidating_scope(self): + """Never over-skip on a reason string we don't recognize.""" + assert classify_failure_scope("weird new error") is FailureScope.MODEL + assert classify_failure_scope(None) is FailureScope.MODEL + assert classify_failure_scope("") is FailureScope.MODEL + + +class TestSameDeployment: + def test_incident_59561_72468_sibling_model_same_provider_is_different(self): + """aux glm-5.2 timing out says nothing about main macaron on the + same custom endpoint — sibling models are independent deployments.""" + failed = _id("custom", "zai-org/glm-5.2") + sibling = _id("custom", "mindai/macaron-v1-venti") + assert not same_deployment(sibling, failed) + assert not should_skip_candidate(sibling, failed, FailureScope.MODEL) + + def test_exact_same_deployment_is_skipped(self): + failed = _id("custom", "zai-org/glm-5.2") + same = _id("custom", "ZAI-ORG/GLM-5.2") # case-insensitive + assert same_deployment(same, failed) + assert should_skip_candidate(same, failed, FailureScope.MODEL) + + def test_incident_62984_same_model_different_explicit_url_is_a_pool(self): + """Several LM Studio endpoints serving one model = a pool, not dups.""" + a = _id("custom", "qwen-3", "http://box1:1234/v1") + b = _id("custom", "qwen-3", "http://box2:1234/v1") + assert not same_deployment(a, b) + assert not should_skip_candidate(a, b, FailureScope.MODEL) + + def test_unknown_url_inherits_provider_default_and_dedups(self): + """An entry without base_url inherits the provider default — it + cannot prove it is a different endpoint (#62984 semantics).""" + a = _id("openrouter", "z-ai/glm-4.7") + b = _id("openrouter", "z-ai/glm-4.7", "https://openrouter.ai/api/v1") + assert same_deployment(a, b) + + def test_incident_22548_shim_aliases_same_url_same_model_are_same(self): + """Two custom_providers aliases at one shim URL with one model.""" + a = _id("claude-cli", "claude-opus-4.7", "http://127.0.0.1:7891/v1") + b = _id("claude-cli-alt", "claude-opus-4.7", "http://127.0.0.1:7891/v1") + assert same_deployment(a, b) + + def test_incident_70893_first_class_pair_same_host_is_not_same(self): + """xai-oauth vs xai share api.x.ai but are distinct backends.""" + fake_registry = {"xai": object(), "xai-oauth": object()} + with patch("hermes_cli.auth.PROVIDER_REGISTRY", fake_registry): + a = _id("xai-oauth", "grok-4.5", "https://api.x.ai/v1") + b = _id("xai", "grok-4.5", "https://api.x.ai/v1") + assert not same_deployment(a, b) + assert not should_skip_candidate(a, b, FailureScope.MODEL) + + +class TestSameCredentialSurface: + def test_same_provider_label_shares_credential(self): + """Auth/payment failure on a provider kills every model on it — + including the main model (the #59561/#72468 carve-out).""" + failed = _id("custom", "zai-org/glm-5.2") + sibling = _id("custom", "mindai/macaron-v1-venti") + assert same_credential_surface(sibling, failed) + assert should_skip_candidate(sibling, failed, FailureScope.CREDENTIAL) + + def test_incident_70893_distinct_first_class_providers_differ(self): + fake_registry = {"xai": object(), "xai-oauth": object()} + with patch("hermes_cli.auth.PROVIDER_REGISTRY", fake_registry): + a = _id("xai", "grok-4.5", "https://api.x.ai/v1") + b = _id("xai-oauth", "grok-4.5", "https://api.x.ai/v1") + assert not same_credential_surface(a, b) + assert not should_skip_candidate(a, b, FailureScope.CREDENTIAL) + + def test_distinct_custom_labels_same_url_not_assumed_shared(self): + """Two custom entries can carry their own api_key each — sameness is + unprovable, so failover must be allowed (conservative direction).""" + a = _id("proxy-a", "m", "http://gw:9000/v1") + b = _id("proxy-b", "m", "http://gw:9000/v1") + assert not same_credential_surface(a, b) + + def test_missing_provider_falls_back_to_url_signal(self): + a = _id("", "m", "http://gw:9000/v1") + b = _id("proxy-b", "m2", "http://gw:9000/v1") + assert same_credential_surface(a, b) + + +class TestSameEndpoint: + def test_same_explicit_url_is_same_endpoint(self): + a = _id("a", "m1", "http://host:8000/v1/") + b = _id("b", "m2", "http://HOST:8000/v1") # trailing slash + case + assert same_endpoint(a, b) + assert should_skip_candidate(a, b, FailureScope.ENDPOINT) + + def test_different_urls_are_different_endpoints(self): + assert not same_endpoint( + _id("a", "m", "http://h1/v1"), _id("a2", "m", "http://h2/v1") + ) + + def test_unknown_url_falls_back_to_provider_default(self): + assert same_endpoint(_id("openrouter", "m1"), _id("openrouter", "m2")) + assert not same_endpoint(_id("openrouter", "m"), _id("nous", "m")) + + +class TestUnknownAxesNeverStrand: + """The failure mode that produced this module: over-skipping. An + unprovable axis must never manufacture a skip.""" + + def test_empty_candidate_never_skipped(self): + failed = _id("custom", "glm", "http://h/v1") + for scope in FailureScope: + assert not should_skip_candidate(_id(), failed, scope), scope + + def test_model_scope_requires_model_evidence(self): + # Failed side has no model recorded → cannot prove the candidate is + # the same deployment → do not skip. + failed = _id("custom") + candidate = _id("custom", "some-model") + assert not should_skip_candidate(candidate, failed, FailureScope.MODEL) From 437b9b1204e67ca582088c6a77c1982ae9b51323 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Mon, 27 Jul 2026 01:44:42 -0500 Subject: [PATCH 53/71] test(desktop): wait for backfill before the duplicate-count baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The large-session-resume E2E captured initialMockReplyCount immediately after openSeededSession, which returns once the NEWEST turn is in the viewport. With FIRST_PAINT_BUDGET=20 (lowered from 60 in this branch), only the newest ~10 turns mount at first paint; the older turns backfill in a rAF. The baseline was reading 10 instead of 27, so once the backfill mounted the full 28 (27 seeded + 1 new), the test saw "28 ≠ 11" and reported duplicates that were never there. Wait for the oldest seeded turn to mount before taking the baseline. This makes the count reflect the fully-mounted transcript regardless of FIRST_PAINT_BUDGET, so the perf win (smaller first paint) and the no-duplicate invariant both hold. Refs #72504 --- apps/desktop/e2e/large-session-resume.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/desktop/e2e/large-session-resume.spec.ts b/apps/desktop/e2e/large-session-resume.spec.ts index b39ab3f1a4d7..02c5f16937d1 100644 --- a/apps/desktop/e2e/large-session-resume.spec.ts +++ b/apps/desktop/e2e/large-session-resume.spec.ts @@ -19,6 +19,12 @@ import { RealSessionBuilder } from './real-session-builder' const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') const SESSION_TITLE = 'E2E large persisted session' const EXPECTED_TEXT = 'E2E persisted user message 52' +// The oldest seeded turn (HISTORY_TURNS[0]). The transcript first paints only +// the newest turns (FIRST_PAINT_BUDGET) and backfills the rest in a rAF; a +// baseline count taken before that backfill sees a clipped transcript and +// falsely reports duplicates once the full list mounts. Waiting for this +// oldest row means the baseline reflects the fully-mounted transcript. +const OLDEST_SEEDED_TEXT = 'E2E persisted user message 0: audit the compatibility matrix' const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume' const HISTORY_TURNS = Array.from( { length: 27 }, @@ -210,6 +216,16 @@ test.describe('large session resume', () => { await waitForAppReady(fixture, 120_000) await openSeededSession(fixture.page) + // The transcript first paints only the newest turns (FIRST_PAINT_BUDGET) + // and backfills older turns in a rAF. Wait for the oldest seeded row to + // mount before taking the baseline so it reflects the full transcript — + // otherwise a clipped baseline makes the backfilled rows look like + // duplicates of the completed reply. + await fixture.page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + OLDEST_SEEDED_TEXT, + { timeout: 30_000 }, + ) const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY) await submitPrompt(fixture.page, BACKGROUND_PROMPT) await fixture.mock.waitForHeldStream() From bbfe181a2a5361b7cc5e93a4e97344852259c745 Mon Sep 17 00:00:00 2001 From: b <b@b> Date: Mon, 27 Jul 2026 01:42:50 -0500 Subject: [PATCH 54/71] perf(desktop): keep thread message component types stable across a session switch --- .../components/assistant-ui/thread/index.tsx | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread/index.tsx b/apps/desktop/src/components/assistant-ui/thread/index.tsx index 946860795e3f..2f26b75a4ed5 100644 --- a/apps/desktop/src/components/assistant-ui/thread/index.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/index.tsx @@ -77,9 +77,21 @@ export const Thread: FC<{ // deps. Only their definedness stays a dep — it gates UI (the user // Stop button, the restore-confirm affordance). Assigned during render // (the useStoreSelector pattern) so the ref never lags a render. + // + // cwd / gateway / sessionId ride the same ref for the same reason, and it + // is load-bearing on the hot path: all three change on EVERY session + // switch, so listing them as deps re-minted these types mid-switch and + // remounted the entire OUTGOING transcript — thousands of renders of a + // thread that was about to be replaced, all of it before the resume RPC + // had even been sent. They are read inside the edit composer (which only + // exists while a message is being edited), never during a plain render, + // so a ref read is always current by the time it matters. const callbacksRef = useRef({ onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage }) callbacksRef.current = { onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage } + const editContextRef = useRef({ cwd, gateway, sessionId }) + editContextRef.current = { cwd, gateway, sessionId } + const hasBranchInNewChat = Boolean(onBranchInNewChat) const hasCancel = Boolean(onCancel) const hasDismissError = Boolean(onDismissError) @@ -96,7 +108,11 @@ export const Thread: FC<{ /> ), SystemMessage, - UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />, + UserEditComposer: () => { + const { cwd: editCwd, gateway: editGateway, sessionId: editSessionId } = editContextRef.current + + return <UserEditComposer cwd={editCwd} gateway={editGateway} sessionId={editSessionId} /> + }, UserMessage: () => ( <UserMessage onCancel={hasCancel ? () => callbacksRef.current.onCancel?.() : undefined} @@ -104,16 +120,7 @@ export const Thread: FC<{ /> ) }), - [ - cwd, - gateway, - hasBranchInNewChat, - hasCancel, - hasDismissError, - hasRestoreToMessage, - requestRestoreConfirm, - sessionId - ] + [hasBranchInNewChat, hasCancel, hasDismissError, hasRestoreToMessage, requestRestoreConfirm] ) const emptyPlaceholder = intro ? ( From 628ce5bb866582f79fe9cc3ef2866450f0533396 Mon Sep 17 00:00:00 2001 From: b <b@b> Date: Mon, 27 Jul 2026 01:48:26 -0500 Subject: [PATCH 55/71] perf(desktop): bail the transcript out of router-driven re-renders on session switch --- .../components/assistant-ui/thread/index.tsx | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread/index.tsx b/apps/desktop/src/components/assistant-ui/thread/index.tsx index 2f26b75a4ed5..8be74ba9e107 100644 --- a/apps/desktop/src/components/assistant-ui/thread/index.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/index.tsx @@ -1,4 +1,4 @@ -import { type FC, useCallback, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import { AssistantMessage } from '@/components/assistant-ui/thread/assistant-message' import { ThreadMessageList } from '@/components/assistant-ui/thread/list' @@ -16,7 +16,7 @@ import { notifyError } from '@/store/notifications' type ThreadLoadingState = 'response' | 'session' -export const Thread: FC<{ +interface ThreadProps { clampToComposer?: boolean cwd?: string | null gateway?: HermesGateway | null @@ -28,7 +28,16 @@ export const Thread: FC<{ onRestoreToMessage?: (messageId: string, target?: RestoreMessageTarget) => Promise<void> | void sessionId?: string | null sessionKey?: string | null -}> = ({ +} + +// memo'd on purpose, and load-bearing for session-switch cost. ChatView +// re-renders on every route change (it reads `location`), and this subtree is +// the entire transcript — without a bail-out here the router's context update +// rebuilds every message of the OUTGOING thread before it is replaced. The +// props above are all stable across a plain re-render (see the component-map +// and loadingIndicator memos below), so the only thing that gets through is a +// genuine change. +export const Thread = memo(function Thread({ clampToComposer = false, cwd = null, gateway = null, @@ -40,7 +49,7 @@ export const Thread: FC<{ onRestoreToMessage, sessionId = null, sessionKey -}) => { +}: ThreadProps) { const { t } = useI18n() const copy = t.assistant.thread @@ -129,13 +138,20 @@ export const Thread: FC<{ </div> ) : undefined + // Stable element identity, for the same reason the component map above is + // memoized: this is a prop of the memo'd ThreadMessageList, so a fresh + // element every render defeats the bail-out and drags the whole transcript + // into the switch's render pass. It takes no props, so one element is + // always correct. + const loadingIndicator = useMemo(() => <BackgroundResumeNotice />, []) + return ( <div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]"> <ThreadMessageList clampToComposer={clampToComposer} components={messageComponents} emptyPlaceholder={emptyPlaceholder} - loadingIndicator={<BackgroundResumeNotice />} + loadingIndicator={loadingIndicator} sessionKey={sessionKey} /> {loading === 'session' && <CenteredThreadSpinner />} @@ -151,4 +167,4 @@ export const Thread: FC<{ /> </div> ) -} +}) From 0b1ee22b9e45142a4ec85ef9f265b65e221e771e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Mon, 27 Jul 2026 01:54:02 -0500 Subject: [PATCH 56/71] fix(tui): paint the OSC-10 default foreground on quantizing terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A skin that authors a background paints both terminal defaults: OSC-11 for the backdrop, OSC-10 to re-base every default-fg token (markdown body, borders, anything rendered without an explicit color) onto the theme's text tone. The OSC-10 half never fired on a limited-palette terminal. `normalizeThemeForAnsiLightTerminal` rewrites the foreground tones to `ansi256(N)`, and `setTerminalForeground` only accepts `#rrggbb` — so the argument failed the hex test and the write was silently skipped. The background moved to the skin while default-fg text stayed on the host profile's foreground. That split is the reported symptom: prose renders in the terminal's own near-black while every themed token beside it renders the skin's gray, so the base text color appears to change between adjacent words. A resize repaints the affected cells from the screen buffer, which is why the text "goes black" on resize and why the mix looks scattered rather than uniform. Resolve the tone through a new `themeToneHex` before handing it to OSC-10: `ansi256(N)` maps through the xterm grayscale ramp and 6x6x6 cube, an authored hex passes through, and anything with no paintable color yields '' (which correctly clears back to the terminal default). Verified on Terminal.app + the `brooklyn` skin: `theme.color.text` is `ansi256(238)`, previously dropped, now emitted as `ESC]10;#444444 BEL` alongside the existing `ESC]11;#f6f9fd BEL`. --- ui-tui/src/__tests__/theme.test.ts | 28 +++++++++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 6 +++-- ui-tui/src/theme.ts | 25 ++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index fb7f7867025a..5496ae6117d0 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -653,3 +653,31 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => { expect(color.statusCritical).toBe(fromSkin({ ui_error: '#dd2222' }, {}).color.error) }) }) + +describe('themeToneHex', () => { + it('resolves a tone to the literal color it paints as', async () => { + const { themeToneHex } = await importThemeWithCleanEnv() + + // 232+ is the grayscale ramp (8 + (n-232)*10); 16-231 is the 6x6x6 cube. + expect(themeToneHex('ansi256(238)')).toBe('#444444') + expect(themeToneHex('ansi256(161)')).toBe('#d7005f') + // An authored hex is already literal. + expect(themeToneHex('#e77fa3')).toBe('#e77fa3') + // No paintable color ⇒ '', which releases the terminal default. + expect(themeToneHex('')).toBe('') + expect(themeToneHex('ansi256(999)')).toBe('') + expect(themeToneHex('inherit')).toBe('') + }) + + it('makes every tone paintable on a quantizing terminal', async () => { + // The contract OSC-10 depends on: whatever the palette normalizer does to + // a tone, themeToneHex still yields a literal `#rrggbb`. Asserted over the + // whole palette so a new tone can't silently regress the default paint. + const { fromSkin, themeToneHex } = await importThemeWithEnv({ TERM_PROGRAM: 'Apple_Terminal' }) + const { color } = fromSkin({ background: '#f6f9fd', ui_text: '#4a4550' }, {}) + + for (const tone of Object.values(color)) { + expect(themeToneHex(tone)).toMatch(/^#[0-9a-f]{6}$/i) + } + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index d8630e216736..bbd826138607 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -22,7 +22,7 @@ import { topLevelSubagents } from '../lib/subagentTree.js' import { isPaintableHex, setTerminalBackground, setTerminalForeground } from '../lib/terminalModes.js' import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js' import { bootSeededPin, invalidateBootBackground, writeBootTheme } from '../lib/themeBoot.js' -import { defaultThemeForCurrentBackground, fromSkin, skinIsLight, type Theme } from '../theme.js' +import { defaultThemeForCurrentBackground, fromSkin, skinIsLight, type Theme, themeToneHex } from '../theme.js' import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' @@ -126,11 +126,13 @@ const themesEqual = (a: Theme, b: Theme) => { // the theme's text color. Without the pair, a dark skin on a light terminal // leaves default-fg text at the HOST's near-black: invisible. Opt-in stays // intact: no `background` ⇒ both defaults restore to the terminal's own. +// The text tone resolves through themeToneHex because a limited-palette +// terminal quantizes it to `ansi256(N)`, which OSC-10 cannot speak. const paintTerminalDefaults = (theme: Theme) => { const background = lastSkin?.colors?.background ?? '' setTerminalBackground(background) - setTerminalForeground(isPaintableHex(background) ? theme.color.text : '') + setTerminalForeground(isPaintableHex(background) ? themeToneHex(theme.color.text) : '') } const applySkin = (s: GatewaySkin) => { diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 7ddd75a4e61d..11ef94131567 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -222,6 +222,31 @@ function normalizeAnsiForeground(color: string): string { return `ansi256(${ansi})` } +const ANSI256_RE = /^ansi256\((\d{1,3})\)$/ + +/** + * The literal `#rrggbb` a theme tone paints as, or '' when it has none. + * + * The inverse of `normalizeAnsiForeground`: tones are not uniformly hex, since + * a limited-palette terminal rewrites the foregrounds to `ansi256(N)`. + * Consumers needing a real color — OSC 10/11, which only speak `#rrggbb` — + * resolve through here rather than hex-testing the tone, which would silently + * skip exactly the terminals that did the quantizing. + */ +export function themeToneHex(tone: string): string { + const ansi = ANSI256_RE.exec(tone.trim()) + + if (ansi) { + const n = Number(ansi[1]) + + return n <= 255 ? toHex(xtermEightBitRgb(n)) : '' + } + + const rgb = parseColor(tone) + + return rgb ? toHex(rgb) : '' +} + // ── Defaults ───────────────────────────────────────────────────────── const BRAND: ThemeBrand = { From 215ec101be1dde26a3e4dabe6944e9789b8ead91 Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:00:22 +0000 Subject: [PATCH 57/71] fmt(js): `npm run fix` on merge (#72522) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> --- .../desktop/src/app/chat/sidebar/sessions-section.tsx | 1 + .../src/app/session/hooks/use-message-stream/index.ts | 1 + .../session/hooks/use-prompt-actions/index.test.tsx | 2 ++ apps/desktop/src/debug/perf-live.ts | 11 +++++------ apps/desktop/src/lib/model-search-text.ts | 2 ++ apps/desktop/src/lib/session-branch-tree.ts | 4 +--- apps/desktop/src/store/subagents.test.ts | 10 ++++++++-- ui-tui/src/lib/model-search-text.test.ts | 7 +------ ui-tui/src/lib/model-search-text.ts | 4 +++- 9 files changed, 24 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx index 4d7578bde94e..b582b5315b02 100644 --- a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -210,6 +210,7 @@ export function SidebarSessionsSection({ // so partition buckets stay truthful — but NEVER for pins, where a turn // finishing was floating background tasks over the user's fixed ranking. const preserveInputOrder = pinned || (sessionsDraggable && !dateGrouped) + const displayEntries = useMemo( () => flattenSessionsWithBranches(sessions, { preserveOrder: preserveInputOrder }), [sessions, preserveInputOrder] diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 8a50465cdc21..d5ec67d384e8 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -257,6 +257,7 @@ export function useMessageStream({ // 30fps of text growth, expensive multi-stream flushes degrade text fps // instead of interactivity — capped so text never updates slower than 4/s. const sinceLast = performance.now() - lastFlushAtRef.current + const adaptiveFloor = Math.min( Math.max(STREAM_DELTA_FLUSH_MS, lastFlushCostRef.current * 3), MAX_STREAM_FLUSH_GAP_MS diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index d3aa1cfbb3b0..a0f0c20b80e6 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -934,6 +934,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => { const focusedSessionId = 'work-runtime-session' const persistedModes = new Map<string, string>() const sessionProfiles = new Map([[focusedSessionId, focusedProfile]]) + const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { if (method === 'slash.exec') { const sessionId = String(params?.session_id ?? '') @@ -949,6 +950,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => { return {} as never }) + let handle: HarnessHandle | null = null render( diff --git a/apps/desktop/src/debug/perf-live.ts b/apps/desktop/src/debug/perf-live.ts index ce73b7738c99..7a15aa5ac6d0 100644 --- a/apps/desktop/src/debug/perf-live.ts +++ b/apps/desktop/src/debug/perf-live.ts @@ -42,7 +42,8 @@ interface LongFrame { scripts: Array<{ invoker: string; ms: number; src: string }> } -const RESIZE_SELECTOR = '[role="separator"], [data-slot="pane-resize-handle"], [class*="cursor-col-resize"], [class*="cursor-row-resize"]' +const RESIZE_SELECTOR = + '[role="separator"], [data-slot="pane-resize-handle"], [class*="cursor-col-resize"], [class*="cursor-row-resize"]' const TYPING_SELECTOR = '[contenteditable="true"], textarea, input[type="text"]' // A gesture is "over" once this long passes with no further input events. @@ -69,7 +70,8 @@ let lastReport: null | Sample = null let longFrames: LongFrame[] = [] const loafObserver = - typeof PerformanceObserver !== 'undefined' && PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame') + typeof PerformanceObserver !== 'undefined' && + PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame') ? new PerformanceObserver(list => { if (!active) { return @@ -100,9 +102,7 @@ const loafObserver = src: (s.sourceURL ?? '').split('/').pop() ?? '' })), // styleAndLayoutStart -> frame end is the engine's style+layout tail. - styleMs: e.styleAndLayoutStart - ? Math.round(e.startTime + e.duration - e.styleAndLayoutStart) - : 0 + styleMs: e.styleAndLayoutStart ? Math.round(e.startTime + e.duration - e.styleAndLayoutStart) : 0 }) } }) @@ -263,7 +263,6 @@ function on() { window.addEventListener('pointermove', onPointerMove, true) window.addEventListener('keydown', onKeyDown, true) - console.log( '%cperf-live%c armed — resize a pane or type in the composer; a report prints when you stop.', 'background:#5a7db0;color:#fff;padding:1px 6px;border-radius:3px', diff --git a/apps/desktop/src/lib/model-search-text.ts b/apps/desktop/src/lib/model-search-text.ts index a370182abe26..5cbe59d3ecae 100644 --- a/apps/desktop/src/lib/model-search-text.ts +++ b/apps/desktop/src/lib/model-search-text.ts @@ -15,11 +15,13 @@ const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = { /** Haystack for fuzzy/substring model search; never changes the wire id. */ export function modelSearchText(model: string): string { const id = model.trim() + if (!id) { return model } const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()] + if (!aliases?.length) { return id } diff --git a/apps/desktop/src/lib/session-branch-tree.ts b/apps/desktop/src/lib/session-branch-tree.ts index c415d873e2f1..07ef3c9911c1 100644 --- a/apps/desktop/src/lib/session-branch-tree.ts +++ b/apps/desktop/src/lib/session-branch-tree.ts @@ -106,9 +106,7 @@ export function flattenSessionsWithBranches( children?.forEach((child, index) => emit(child, index === children.length - 1 ? '└─ ' : '├─ ')) } - const roots = sessions - .filter(session => !nestedIds.has(session.id)) - .map((session, index) => ({ index, session })) + const roots = sessions.filter(session => !nestedIds.has(session.id)).map((session, index) => ({ index, session })) if (!options.preserveOrder) { roots.sort((a, b) => groupRecency(b.session) - groupRecency(a.session) || a.index - b.index) diff --git a/apps/desktop/src/store/subagents.test.ts b/apps/desktop/src/store/subagents.test.ts index 51c752ddea22..003d7e168a33 100644 --- a/apps/desktop/src/store/subagents.test.ts +++ b/apps/desktop/src/store/subagents.test.ts @@ -147,7 +147,9 @@ describe('subagent store', () => { pruneFinishedSessionSubagents('s1') - const ids = listFor('s1').map(item => item.id).sort() + const ids = listFor('s1') + .map(item => item.id) + .sort() expect(ids).toEqual(['live-a', 'live-b']) expect(activeSubagentCount(listFor('s1'))).toBe(2) }) @@ -181,6 +183,10 @@ describe('subagent store', () => { pruneFinishedSessionSubagents('s1') expect(listFor('s1').map(item => item.id)).toEqual(['a']) - expect(listFor('s2').map(item => item.id).sort()).toEqual(['c', 'd']) + expect( + listFor('s2') + .map(item => item.id) + .sort() + ).toEqual(['c', 'd']) }) }) diff --git a/ui-tui/src/lib/model-search-text.test.ts b/ui-tui/src/lib/model-search-text.test.ts index 9f8d3a0c5b36..5ef8e612c0e3 100644 --- a/ui-tui/src/lib/model-search-text.test.ts +++ b/ui-tui/src/lib/model-search-text.test.ts @@ -16,12 +16,7 @@ describe('modelSearchText', () => { }) describe('model picker search with aliases', () => { - const models = [ - 'kimi-k2.6', - 'kimi-k2.5', - 'k3', - 'kimi-for-coding', - ] + const models = ['kimi-k2.6', 'kimi-k2.5', 'k3', 'kimi-for-coding'] it('surfaces k3 when the user searches kimi', () => { const ranked = fuzzyRank(models, 'kimi', modelSearchText).map(r => r.item) diff --git a/ui-tui/src/lib/model-search-text.ts b/ui-tui/src/lib/model-search-text.ts index 3495e315ceb4..bc7732ad696e 100644 --- a/ui-tui/src/lib/model-search-text.ts +++ b/ui-tui/src/lib/model-search-text.ts @@ -9,17 +9,19 @@ * hermes_cli/model_search.py. */ const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = { - k3: ['kimi-k3', 'kimi'], + k3: ['kimi-k3', 'kimi'] } /** Haystack for fuzzy/substring model search; never changes the wire id. */ export function modelSearchText(model: string): string { const id = model.trim() + if (!id) { return model } const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()] + if (!aliases?.length) { return id } From 3bd2574d2ae9159d8786969dbae92cb374007122 Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:16:27 +0000 Subject: [PATCH 58/71] fmt(js): `npm run fix` on merge (#72532) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> --- apps/desktop/src/debug/perf-live.ts | 1 + apps/desktop/src/store/subagents.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/desktop/src/debug/perf-live.ts b/apps/desktop/src/debug/perf-live.ts index 7a15aa5ac6d0..17a71a92303c 100644 --- a/apps/desktop/src/debug/perf-live.ts +++ b/apps/desktop/src/debug/perf-live.ts @@ -44,6 +44,7 @@ interface LongFrame { const RESIZE_SELECTOR = '[role="separator"], [data-slot="pane-resize-handle"], [class*="cursor-col-resize"], [class*="cursor-row-resize"]' + const TYPING_SELECTOR = '[contenteditable="true"], textarea, input[type="text"]' // A gesture is "over" once this long passes with no further input events. diff --git a/apps/desktop/src/store/subagents.test.ts b/apps/desktop/src/store/subagents.test.ts index 003d7e168a33..254a7a22f476 100644 --- a/apps/desktop/src/store/subagents.test.ts +++ b/apps/desktop/src/store/subagents.test.ts @@ -150,6 +150,7 @@ describe('subagent store', () => { const ids = listFor('s1') .map(item => item.id) .sort() + expect(ids).toEqual(['live-a', 'live-b']) expect(activeSubagentCount(listFor('s1'))).toBe(2) }) From 323033f21f1756ac74960e74d20afe0353018f41 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Mon, 27 Jul 2026 02:17:27 -0500 Subject: [PATCH 59/71] ci: retrigger checks (GitHub Actions failed to resolve workflow file) From 6898e5a3553ba762556057c97723ca6db7018714 Mon Sep 17 00:00:00 2001 From: Fangliquan <fangliquan@oppo.com> Date: Sun, 26 Jul 2026 10:46:38 +0800 Subject: [PATCH 60/71] fix(hermes_cli): remove restartable root chown from s6 gateway log/run Root-context log/run used to pathname-chown hermes-writable log paths, which a hermes user can race through a symlink swap via the writable log control FIFO. Create the leaf with s6-setuidgid hermes mkdir instead; parent logs/gateways ownership stays a stage2 boot concern (#45258). --- hermes_cli/service_manager.py | 23 +-- tests/hermes_cli/test_service_manager.py | 232 +++++++++++++++++++++-- 2 files changed, 223 insertions(+), 32 deletions(-) diff --git a/hermes_cli/service_manager.py b/hermes_cli/service_manager.py index 471c6a412cb6..b01fce9302c3 100644 --- a/hermes_cli/service_manager.py +++ b/hermes_cli/service_manager.py @@ -783,17 +783,18 @@ class S6ServiceManager: f"# shellcheck shell=sh\n" f': "${{HERMES_HOME:=/opt/data}}"\n' f'log_dir="$HERMES_HOME/logs/gateways/{prof}"\n' - f'mkdir -p "$log_dir"\n' - # The gateways/ parent must be chowned too (non-recursively): - # `mkdir -p` creates it root-owned on a root-context boot, and a - # leaf-only chown leaves it that way — every profile registered - # later then runs its log service as hermes and crash-loops on - # `mkdir: Permission denied`. The parent chown runs on every - # root-context boot, so it also heals volumes already poisoned - # by older images. Non-recursive on purpose: sibling profile - # dirs are each managed by their own log/run. See #45258. - f'chown hermes:hermes "$HERMES_HOME/logs/gateways" 2>/dev/null || true\n' - f'chown -R hermes:hermes "$log_dir" 2>/dev/null || true\n' + # Create the leaf as hermes when this script starts as root. + # Never chown hermes-writable volume paths from this restartable + # root-context script: log/supervise/control is hermes-owned, so + # an unprivileged user can race a pathname check/chown through a + # symlink swap (CWE-59 / CWE-367). Parent logs/gateways is seeded + # hermes-owned at stage2 boot (#45258; + # tests/docker/test_log_dir_seed.py). + f'if [ "$(id -u)" = 0 ]; then\n' + f' s6-setuidgid hermes mkdir -p "$log_dir"\n' + f'else\n' + f' mkdir -p "$log_dir"\n' + f'fi\n' f'rm -f "$log_dir/lock"\n' # Skip the drop when already non-root (CAP_SETGID). f'[ "$(id -u)" = 0 ] || exec s6-log 1 n10 s1000000 T "$log_dir"\n' diff --git a/tests/hermes_cli/test_service_manager.py b/tests/hermes_cli/test_service_manager.py index 21107c248dc3..99826b08cf77 100644 --- a/tests/hermes_cli/test_service_manager.py +++ b/tests/hermes_cli/test_service_manager.py @@ -1103,36 +1103,226 @@ def test_s6_stop_tolerates_marker_write_failure(monkeypatch, s6_scandir): assert any(cmd[0] == "s6-svc" and "-d" in cmd for cmd in svc_calls) -def test_s6_log_run_chowns_gateways_parent(s6_scandir, fake_subprocess_run) -> None: - """The log/run script must chown the logs/gateways/ parent, not just the leaf. +def _log_run_setup_fragment(rendered: str) -> str: + """Keep mkdir/rm setup from ``_render_log_run``; stop before ``s6-log``.""" + keep: list[str] = [] + for line in rendered.splitlines(keepends=True): + if line.startswith("#!/") or "shellcheck" in line: + continue + if "s6-log" in line: + break + keep.append(line) + return "#!/bin/sh\n" + "".join(keep) - Regression guard for #45258: `mkdir -p` creates the gateways/ parent - root-owned on a root-context boot, and a leaf-only chown leaves it that - way. Every profile registered later then runs its log service as the - dropped hermes user and s6-log crash-loops on `mkdir: Permission denied`. + +def test_s6_log_run_creates_leaf_as_hermes_without_chown( + s6_scandir, fake_subprocess_run, +) -> None: + """log/run must not root-chown volume paths; create the leaf as hermes. + + #45258 parent ownership is stage2's job (``logs/gateways`` seeded as + hermes). Restartable log/run must not pathname-chown a hermes-writable + tree from root — that is a symlink TOCTOU privilege-escalation hole. """ mgr = S6ServiceManager(scandir=s6_scandir) mgr.register_profile_gateway("coder") log_text = (s6_scandir / "gateway-coder" / "log" / "run").read_text() - parent_chown = 'chown hermes:hermes "$HERMES_HOME/logs/gateways"' - assert parent_chown in log_text, ( - "log/run must chown the logs/gateways parent so profiles added " - f"after a root-context boot can create their leaf dirs. Saw: {log_text!r}" + assert not any(line.lstrip().startswith("chown ") for line in log_text.splitlines()), ( + "restartable log/run must not invoke chown on hermes-writable paths; " + f"saw: {log_text!r}" ) - # Non-recursive on purpose: sibling profile leaf dirs are each managed - # by their own log/run; a recursive parent chown would race them. - assert 'chown -R hermes:hermes "$HERMES_HOME/logs/gateways"' not in log_text + assert 's6-setuidgid hermes mkdir -p "$log_dir"' in log_text + assert 'mkdir -p "$log_dir"' in log_text - # Ordering: mkdir creates the parent, then the parent chown repairs its - # ownership, then the leaf chown — all before s6-log execs. - mkdir_idx = log_text.index('mkdir -p "$log_dir"') - parent_idx = log_text.index(parent_chown) - leaf_idx = log_text.index('chown -R hermes:hermes "$log_dir"') + mkdir_as_hermes_idx = log_text.index('s6-setuidgid hermes mkdir -p "$log_dir"') exec_idx = log_text.index("s6-log 1 ") - assert mkdir_idx < parent_idx < leaf_idx < exec_idx + assert mkdir_as_hermes_idx < exec_idx - # The parent path must be a runtime env expansion, never a baked-in - # absolute path (same contract as the log_dir itself). + # Runtime path expansion, never a baked-in absolute path. assert '/opt/data/logs/gateways"' not in log_text + + +def test_s6_log_run_never_invokes_chown_with_symlinked_log_dir(tmp_path) -> None: + """Symlinked ``$log_dir`` must not cause any chown of the referent.""" + import os + import stat + import subprocess + import threading + import time + + import pytest + + if os.name == "nt": + pytest.skip("POSIX symlink + /bin/sh required") + + hermes_home = tmp_path / "hermes" + gateways = hermes_home / "logs" / "gateways" + gateways.mkdir(parents=True) + leaf = gateways / "coder" + leaf.mkdir() + + victim = tmp_path / "victim" + victim.mkdir() + (victim / "marker").write_text("keep", encoding="utf-8") + before = victim.stat() + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + recorder = tmp_path / "chown_calls.txt" + (bin_dir / "chown").write_text( + "#!/bin/sh\n" + f'printf "%s\\n" "$*" >> "{recorder.as_posix()}"\n' + "exit 0\n", + encoding="utf-8", + ) + # Pretend we are root so the script takes the s6-setuidgid mkdir path, + # and make s6-setuidgid a no-op drop that just runs the command. + (bin_dir / "id").write_text( + "#!/bin/sh\n" + 'if [ "$1" = "-u" ]; then echo 0; exit 0; fi\n' + "exit 1\n", + encoding="utf-8", + ) + (bin_dir / "s6-setuidgid").write_text( + "#!/bin/sh\n" + "shift\n" + 'exec "$@"\n', + encoding="utf-8", + ) + for name in ("chown", "id", "s6-setuidgid"): + p = bin_dir / name + p.chmod(p.stat().st_mode | stat.S_IXUSR) + + script_path = tmp_path / "log_run_setup.sh" + script_path.write_text( + _log_run_setup_fragment(S6ServiceManager._render_log_run("coder")), + encoding="utf-8", + ) + script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) + + stop = threading.Event() + + def _clear_leaf() -> None: + if leaf.is_symlink(): + leaf.unlink() + elif leaf.is_dir(): + leaf.rmdir() + elif leaf.exists(): + leaf.unlink() + + def _swap_race() -> None: + # Alternate leaf between a real dir and a symlink to the victim while + # the setup fragment runs — proves there is no privileged chown window + # to win, unlike a check-then-chown preflight. + while not stop.is_set(): + try: + _clear_leaf() + leaf.symlink_to(victim) + time.sleep(0.001) + _clear_leaf() + leaf.mkdir() + except OSError: + pass + time.sleep(0.001) + + env = os.environ.copy() + env["HERMES_HOME"] = str(hermes_home) + env["PATH"] = f"{bin_dir.as_posix()}{os.pathsep}{env.get('PATH', '')}" + + racer = threading.Thread(target=_swap_race, daemon=True) + racer.start() + try: + for _ in range(40): + proc = subprocess.run( + ["/bin/sh", str(script_path)], + env=env, + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 0, (proc.stdout, proc.stderr) + finally: + stop.set() + racer.join(timeout=2) + + assert not recorder.exists() or recorder.read_text(encoding="utf-8").strip() == "" + after = victim.stat() + assert after.st_uid == before.st_uid + assert after.st_gid == before.st_gid + assert (victim / "marker").read_text(encoding="utf-8") == "keep" + + +def test_s6_log_run_mkdir_as_hermes_on_real_dirs(tmp_path) -> None: + """Root-context setup creates ``$log_dir`` via ``s6-setuidgid hermes mkdir``.""" + import os + import stat + import subprocess + + import pytest + + if os.name == "nt": + pytest.skip("POSIX /bin/sh required") + + hermes_home = tmp_path / "hermes" + (hermes_home / "logs" / "gateways").mkdir(parents=True) + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + mkdir_recorder = tmp_path / "mkdir_via_setuidgid.txt" + chown_recorder = tmp_path / "chown_calls.txt" + + (bin_dir / "id").write_text( + "#!/bin/sh\n" + 'if [ "$1" = "-u" ]; then echo 0; exit 0; fi\n' + "exit 1\n", + encoding="utf-8", + ) + (bin_dir / "s6-setuidgid").write_text( + "#!/bin/sh\n" + f'printf "%s\\n" "$*" >> "{mkdir_recorder.as_posix()}"\n' + "shift\n" + 'exec "$@"\n', + encoding="utf-8", + ) + (bin_dir / "chown").write_text( + "#!/bin/sh\n" + f'printf "%s\\n" "$*" >> "{chown_recorder.as_posix()}"\n' + "exit 0\n", + encoding="utf-8", + ) + for name in ("id", "s6-setuidgid", "chown"): + p = bin_dir / name + p.chmod(p.stat().st_mode | stat.S_IXUSR) + + script_path = tmp_path / "log_run_setup.sh" + script_path.write_text( + _log_run_setup_fragment(S6ServiceManager._render_log_run("coder")), + encoding="utf-8", + ) + script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) + + env = os.environ.copy() + env["HERMES_HOME"] = str(hermes_home) + env["PATH"] = f"{bin_dir.as_posix()}{os.pathsep}{env.get('PATH', '')}" + + proc = subprocess.run( + ["/bin/sh", str(script_path)], + env=env, + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 0, (proc.stdout, proc.stderr) + + mkdir_calls = mkdir_recorder.read_text(encoding="utf-8").strip().splitlines() + assert any( + c.split()[:3] == ["hermes", "mkdir", "-p"] and "gateways/coder" in c + for c in mkdir_calls + ), mkdir_calls + assert (hermes_home / "logs" / "gateways" / "coder").is_dir() + assert ( + not chown_recorder.exists() + or chown_recorder.read_text(encoding="utf-8").strip() == "" + ) From ad84330ad0a123305567c25fb62a1105168c9519 Mon Sep 17 00:00:00 2001 From: Fangliquan <fangliquan@oppo.com> Date: Sun, 26 Jul 2026 10:46:55 +0800 Subject: [PATCH 61/71] fix(hermes_cli): heal root-owned logs/gateways on every stage2 boot Without restartable log/run chown, warm volumes that keep a hermes-owned HERMES_HOME but root-owned logs/gateways would again deny hermes mkdir. Add a non-recursive stage2 parent heal and cover the poisoned-parent reboot path. --- docker/stage2-hook.sh | 15 +++++++ tests/docker/test_log_dir_seed.py | 53 +++++++++++++++++++++++- tests/hermes_cli/test_service_manager.py | 2 +- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index bd722da7319d..05474ca927b4 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -287,6 +287,21 @@ if [ -d "$HERMES_HOME/cron" ]; then chown_hermes_tree "$HERMES_HOME/cron" fi +# Always ensure logs/gateways is hermes-owned (#45258). Formerly healed by +# restartable gateway log/run chown — removed due to symlink TOCTOU +# (CWE-59/367). The targeted data-volume chown above only runs when the +# top-level $HERMES_HOME is mis-owned, so a warm volume with hermes-owned +# HERMES_HOME but root-owned logs/gateways would otherwise leave +# s6-setuidgid hermes mkdir failing with Permission denied. Non-recursive: +# profile leaf dirs are each created/owned by their own log/run as hermes. +if [ -d "$HERMES_HOME/logs/gateways" ]; then + if refuse_symlinked_path "chown" "$HERMES_HOME/logs/gateways"; then + : + else + chown hermes:hermes "$HERMES_HOME/logs/gateways" 2>/dev/null || true + fi +fi + # Always reset ownership of pairing data on every boot, same docker-exec/ # root-write reason as profiles/ and cron/. `docker exec <container> # hermes pairing approve …` defaults to uid=0 and writes 0600 root-owned diff --git a/tests/docker/test_log_dir_seed.py b/tests/docker/test_log_dir_seed.py index 2893da2814da..554fac9d2ecd 100644 --- a/tests/docker/test_log_dir_seed.py +++ b/tests/docker/test_log_dir_seed.py @@ -10,7 +10,11 @@ s6-log crash-loops on mkdir: Permission denied. """ from __future__ import annotations -from tests.docker.conftest import docker_exec_sh, start_container +from tests.docker.conftest import ( + docker_exec_sh, + restart_container, + start_container, +) def test_logs_gateways_seeded_and_hermes_owned( @@ -45,3 +49,50 @@ def test_logs_gateways_seeded_and_hermes_owned( assert "gateways=hermes" in r.stdout, ( f"logs/gateways/ not owned by hermes: {r.stdout}" ) + + +def test_logs_gateways_healed_when_parent_root_owned( + built_image: str, container_name: str, +) -> None: + """Warm-boot stage2 must heal root-owned logs/gateways (#45258). + + Mimics a poisoned volume: HERMES_HOME already hermes-owned (so the + bulk data-volume chown is skipped) while logs/gateways is root-owned. + Restartable log/run no longer root-chowns that path (symlink TOCTOU), + so stage2 must repair the parent on every boot. + """ + start_container(built_image, container_name) + + poison = docker_exec_sh( + container_name, + "chown root:root /opt/data/logs/gateways && " + 'home_owner=$(stat -c "%U" /opt/data); ' + 'gateways_owner=$(stat -c "%U" /opt/data/logs/gateways); ' + 'echo "home=$home_owner gateways=$gateways_owner"', + user="root", + timeout=10, + ) + assert poison.returncode == 0, (poison.stdout, poison.stderr) + assert "home=hermes" in poison.stdout, poison.stdout + assert "gateways=root" in poison.stdout, poison.stdout + + denied = docker_exec_sh( + container_name, + "mkdir -p /opt/data/logs/gateways/poison-probe 2>/dev/null " + "&& echo MKDIR_OK || echo MKDIR_DENIED", + timeout=10, + ) + assert "MKDIR_DENIED" in denied.stdout, denied.stdout + + restart_container(container_name) + + healed = docker_exec_sh( + container_name, + 'gateways_owner=$(stat -c "%U" /opt/data/logs/gateways); ' + "mkdir -p /opt/data/logs/gateways/poison-probe && " + 'echo "gateways=$gateways_owner MKDIR_OK"', + timeout=10, + ) + assert healed.returncode == 0, (healed.stdout, healed.stderr) + assert "gateways=hermes" in healed.stdout, healed.stdout + assert "MKDIR_OK" in healed.stdout, healed.stdout diff --git a/tests/hermes_cli/test_service_manager.py b/tests/hermes_cli/test_service_manager.py index 99826b08cf77..e05df52a58e3 100644 --- a/tests/hermes_cli/test_service_manager.py +++ b/tests/hermes_cli/test_service_manager.py @@ -1134,7 +1134,7 @@ def test_s6_log_run_creates_leaf_as_hermes_without_chown( f"saw: {log_text!r}" ) assert 's6-setuidgid hermes mkdir -p "$log_dir"' in log_text - assert 'mkdir -p "$log_dir"' in log_text + assert 'else\n mkdir -p "$log_dir"\nfi\n' in log_text mkdir_as_hermes_idx = log_text.index('s6-setuidgid hermes mkdir -p "$log_dir"') exec_idx = log_text.index("s6-log 1 ") From 4be89059aeca1b00a57892c3aa81795e1a7eea55 Mon Sep 17 00:00:00 2001 From: Fangliquan <fangliquan@oppo.com> Date: Mon, 27 Jul 2026 09:40:34 +0800 Subject: [PATCH 62/71] fix(hermes_cli): drop privileges before clearing s6-log lock Root-context rm -f "$log_dir/lock" could follow a raced directory symlink and unlink a foreign lock outside HERMES_HOME. Clear the stale lock via s6-setuidgid hermes alongside mkdir, and assert victim/lock survives the swap-race test. --- hermes_cli/service_manager.py | 17 +++---- tests/hermes_cli/test_service_manager.py | 60 +++++++++++++++++------- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/hermes_cli/service_manager.py b/hermes_cli/service_manager.py index b01fce9302c3..2fe55314896b 100644 --- a/hermes_cli/service_manager.py +++ b/hermes_cli/service_manager.py @@ -783,19 +783,20 @@ class S6ServiceManager: f"# shellcheck shell=sh\n" f': "${{HERMES_HOME:=/opt/data}}"\n' f'log_dir="$HERMES_HOME/logs/gateways/{prof}"\n' - # Create the leaf as hermes when this script starts as root. - # Never chown hermes-writable volume paths from this restartable - # root-context script: log/supervise/control is hermes-owned, so - # an unprivileged user can race a pathname check/chown through a - # symlink swap (CWE-59 / CWE-367). Parent logs/gateways is seeded - # hermes-owned at stage2 boot (#45258; - # tests/docker/test_log_dir_seed.py). + # Create the leaf and clear a stale s6-log lock as hermes when + # this script starts as root. Never chown or unlink hermes-writable + # volume paths from this restartable root-context script: + # log/supervise/control is hermes-owned, so an unprivileged user + # can race a pathname op through a symlink swap (CWE-59 / + # CWE-367). Parent logs/gateways is seeded hermes-owned at stage2 + # boot (#45258; tests/docker/test_log_dir_seed.py). f'if [ "$(id -u)" = 0 ]; then\n' f' s6-setuidgid hermes mkdir -p "$log_dir"\n' + f' s6-setuidgid hermes rm -f "$log_dir/lock"\n' f'else\n' f' mkdir -p "$log_dir"\n' + f' rm -f "$log_dir/lock"\n' f'fi\n' - f'rm -f "$log_dir/lock"\n' # Skip the drop when already non-root (CAP_SETGID). f'[ "$(id -u)" = 0 ] || exec s6-log 1 n10 s1000000 T "$log_dir"\n' f'exec s6-setuidgid hermes s6-log 1 n10 s1000000 T "$log_dir"\n' diff --git a/tests/hermes_cli/test_service_manager.py b/tests/hermes_cli/test_service_manager.py index e05df52a58e3..6a6f3a3bbb84 100644 --- a/tests/hermes_cli/test_service_manager.py +++ b/tests/hermes_cli/test_service_manager.py @@ -1118,11 +1118,11 @@ def _log_run_setup_fragment(rendered: str) -> str: def test_s6_log_run_creates_leaf_as_hermes_without_chown( s6_scandir, fake_subprocess_run, ) -> None: - """log/run must not root-chown volume paths; create the leaf as hermes. + """log/run must not root-chown/unlink volume paths; create leaf as hermes. #45258 parent ownership is stage2's job (``logs/gateways`` seeded as - hermes). Restartable log/run must not pathname-chown a hermes-writable - tree from root — that is a symlink TOCTOU privilege-escalation hole. + hermes). Restartable log/run must not pathname-chown or pathname-rm a + hermes-writable tree from root — that is a symlink TOCTOU hole. """ mgr = S6ServiceManager(scandir=s6_scandir) mgr.register_profile_gateway("coder") @@ -1134,18 +1134,23 @@ def test_s6_log_run_creates_leaf_as_hermes_without_chown( f"saw: {log_text!r}" ) assert 's6-setuidgid hermes mkdir -p "$log_dir"' in log_text - assert 'else\n mkdir -p "$log_dir"\nfi\n' in log_text + assert 's6-setuidgid hermes rm -f "$log_dir/lock"' in log_text + assert 'else\n mkdir -p "$log_dir"\n rm -f "$log_dir/lock"\nfi\n' in log_text + # Lock cleanup must not remain a bare root-context pathname op after fi. + after_fi = log_text.split("fi\n", 1)[-1] + assert 'rm -f "$log_dir/lock"' not in after_fi mkdir_as_hermes_idx = log_text.index('s6-setuidgid hermes mkdir -p "$log_dir"') + rm_as_hermes_idx = log_text.index('s6-setuidgid hermes rm -f "$log_dir/lock"') exec_idx = log_text.index("s6-log 1 ") - assert mkdir_as_hermes_idx < exec_idx + assert mkdir_as_hermes_idx < rm_as_hermes_idx < exec_idx # Runtime path expansion, never a baked-in absolute path. assert '/opt/data/logs/gateways"' not in log_text def test_s6_log_run_never_invokes_chown_with_symlinked_log_dir(tmp_path) -> None: - """Symlinked ``$log_dir`` must not cause any chown of the referent.""" + """Symlinked ``$log_dir`` must not redirect root chown/rm to the referent.""" import os import stat import subprocess @@ -1166,6 +1171,7 @@ def test_s6_log_run_never_invokes_chown_with_symlinked_log_dir(tmp_path) -> None victim = tmp_path / "victim" victim.mkdir() (victim / "marker").write_text("keep", encoding="utf-8") + (victim / "lock").write_text("keep-lock", encoding="utf-8") before = victim.stat() bin_dir = tmp_path / "bin" @@ -1177,8 +1183,9 @@ def test_s6_log_run_never_invokes_chown_with_symlinked_log_dir(tmp_path) -> None "exit 0\n", encoding="utf-8", ) - # Pretend we are root so the script takes the s6-setuidgid mkdir path, - # and make s6-setuidgid a no-op drop that just runs the command. + # Pretend we are root so the script takes the s6-setuidgid setup path. + # Mark the drop so fake rm can refuse unlink outside HERMES_HOME the way + # a real hermes uid cannot delete a foreign root-owned lock. (bin_dir / "id").write_text( "#!/bin/sh\n" 'if [ "$1" = "-u" ]; then echo 0; exit 0; fi\n' @@ -1188,10 +1195,23 @@ def test_s6_log_run_never_invokes_chown_with_symlinked_log_dir(tmp_path) -> None (bin_dir / "s6-setuidgid").write_text( "#!/bin/sh\n" "shift\n" - 'exec "$@"\n', + 'HERMES_TEST_DROPPED=1 exec "$@"\n', encoding="utf-8", ) - for name in ("chown", "id", "s6-setuidgid"): + real_rm = "/bin/rm" + (bin_dir / "rm").write_text( + "#!/bin/sh\n" + # Privilege-dropped: no-op. Models that hermes cannot unlink a foreign + # root-owned lock outside the volume; avoids a realpath/rm TOCTOU in + # the test double itself. Root-context: real rm — a residual bare + # ``rm -f "$log_dir/lock"`` would delete victim/lock via the symlink. + 'if [ -n "$HERMES_TEST_DROPPED" ]; then\n' + " exit 0\n" + "fi\n" + f'exec {real_rm} "$@"\n', + encoding="utf-8", + ) + for name in ("chown", "id", "s6-setuidgid", "rm"): p = bin_dir / name p.chmod(p.stat().st_mode | stat.S_IXUSR) @@ -1214,8 +1234,8 @@ def test_s6_log_run_never_invokes_chown_with_symlinked_log_dir(tmp_path) -> None def _swap_race() -> None: # Alternate leaf between a real dir and a symlink to the victim while - # the setup fragment runs — proves there is no privileged chown window - # to win, unlike a check-then-chown preflight. + # the setup fragment runs — proves there is no privileged chown/rm + # window to win, unlike a check-then-use preflight. while not stop.is_set(): try: _clear_leaf() @@ -1243,6 +1263,7 @@ def test_s6_log_run_never_invokes_chown_with_symlinked_log_dir(tmp_path) -> None check=False, ) assert proc.returncode == 0, (proc.stdout, proc.stderr) + assert (victim / "lock").is_file(), "symlinked leaf must not let root unlink victim/lock" finally: stop.set() racer.join(timeout=2) @@ -1252,6 +1273,7 @@ def test_s6_log_run_never_invokes_chown_with_symlinked_log_dir(tmp_path) -> None assert after.st_uid == before.st_uid assert after.st_gid == before.st_gid assert (victim / "marker").read_text(encoding="utf-8") == "keep" + assert (victim / "lock").read_text(encoding="utf-8") == "keep-lock" def test_s6_log_run_mkdir_as_hermes_on_real_dirs(tmp_path) -> None: @@ -1270,7 +1292,7 @@ def test_s6_log_run_mkdir_as_hermes_on_real_dirs(tmp_path) -> None: bin_dir = tmp_path / "bin" bin_dir.mkdir() - mkdir_recorder = tmp_path / "mkdir_via_setuidgid.txt" + setuid_recorder = tmp_path / "setuidgid_calls.txt" chown_recorder = tmp_path / "chown_calls.txt" (bin_dir / "id").write_text( @@ -1281,7 +1303,7 @@ def test_s6_log_run_mkdir_as_hermes_on_real_dirs(tmp_path) -> None: ) (bin_dir / "s6-setuidgid").write_text( "#!/bin/sh\n" - f'printf "%s\\n" "$*" >> "{mkdir_recorder.as_posix()}"\n' + f'printf "%s\\n" "$*" >> "{setuid_recorder.as_posix()}"\n' "shift\n" 'exec "$@"\n', encoding="utf-8", @@ -1316,11 +1338,15 @@ def test_s6_log_run_mkdir_as_hermes_on_real_dirs(tmp_path) -> None: ) assert proc.returncode == 0, (proc.stdout, proc.stderr) - mkdir_calls = mkdir_recorder.read_text(encoding="utf-8").strip().splitlines() + setuid_calls = setuid_recorder.read_text(encoding="utf-8").strip().splitlines() assert any( c.split()[:3] == ["hermes", "mkdir", "-p"] and "gateways/coder" in c - for c in mkdir_calls - ), mkdir_calls + for c in setuid_calls + ), setuid_calls + assert any( + c.split()[:3] == ["hermes", "rm", "-f"] and c.rstrip().endswith("/lock") + for c in setuid_calls + ), setuid_calls assert (hermes_home / "logs" / "gateways" / "coder").is_dir() assert ( not chown_recorder.exists() From 5a55ce7dd5d382c1bbdaa724252c62cb92ad9369 Mon Sep 17 00:00:00 2001 From: homelab-ha-agent <ha-agent@homelab.4410.us> Date: Wed, 1 Jul 2026 18:45:55 -0400 Subject: [PATCH 63/71] fix(model): don't strip alias-derived prefixes for the custom provider bucket _MATCHING_PREFIX_STRIP_PROVIDERS includes "custom" so that manually typed config values like "zai/glm-5.1" repair themselves for their matching native provider. But "custom" is a generic bucket for arbitrary user-defined endpoints, not a vendor identity -- unlike zai/gemini/xai, where a matching alias really does mean "this prefix names the same backend as the target provider." _PROVIDER_ALIASES maps "ollama" -> "custom", so a model configured as "ollama/glm-5.2" against a named custom provider (e.g. a LiteLLM proxy fronting Ollama, which registers its routes as "ollama/<model>") had its prefix stripped to bare "glm-5.2" -- a name the proxy doesn't recognize. _strip_matching_provider_prefix now only strips a literal "custom/" prefix when the target resolves to "custom"; an alias that merely resolves to custom (ollama) no longer qualifies, since custom has no vendor identity for it to redundantly repeat. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- hermes_cli/model_normalize.py | 15 ++++++++++++++- tests/hermes_cli/test_model_normalize.py | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 8c4a31b25d2e..c2865b0364ba 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -245,6 +245,14 @@ def _strip_matching_provider_prefix(model_name: str, target_provider: str) -> st This prevents arbitrary slash-bearing model IDs from being mangled on native providers while still repairing manual config values like ``zai/glm-5.1`` for the ``zai`` provider. + + ``custom`` is a generic bucket for arbitrary user-defined endpoints, not + a vendor identity like ``zai``/``gemini``/``xai``. An alias that merely + *resolves to* ``custom`` (e.g. ``ollama``, via ``_PROVIDER_ALIASES``) + does not mean a ``ollama/`` prefix is redundant -- it may be the actual + routing prefix a proxy in front of the custom endpoint (e.g. LiteLLM) + requires, as in ``ollama/glm-5.2``. Only a literal ``custom/`` prefix -- + the bucket's own name -- is treated as redundant here. """ if "/" not in model_name: return model_name @@ -253,8 +261,13 @@ def _strip_matching_provider_prefix(model_name: str, target_provider: str) -> st if not prefix.strip() or not remainder.strip(): return model_name - normalized_prefix = _normalize_provider_alias(prefix) normalized_target = _normalize_provider_alias(target_provider) + if normalized_target == "custom": + if prefix.strip().lower() == "custom": + return remainder.strip() + return model_name + + normalized_prefix = _normalize_provider_alias(prefix) if normalized_prefix and normalized_prefix == normalized_target: return remainder.strip() return model_name diff --git a/tests/hermes_cli/test_model_normalize.py b/tests/hermes_cli/test_model_normalize.py index 564c188e96e4..a11389aef1d2 100644 --- a/tests/hermes_cli/test_model_normalize.py +++ b/tests/hermes_cli/test_model_normalize.py @@ -182,6 +182,27 @@ class TestIssue6211NativeProviderPrefixNormalization: assert normalize_model_for_provider(model, target_provider) == expected +class TestCustomProviderIsNotAVendorIdentity: + """``custom`` is a generic bucket, not a vendor -- an alias that merely + *resolves to* ``custom`` (e.g. ``ollama`` -> ``custom`` in + ``_PROVIDER_ALIASES``) must not be treated as a redundant prefix the + way ``zai/``, ``gemini/``, etc. are for their own native providers. + + Regression for: a named custom provider (e.g. a LiteLLM proxy fronting + Ollama) registers its own routing name as ``ollama/glm-5.2``. Stripping + the ``ollama/`` prefix because it happens to alias to ``custom`` + produced a bare ``glm-5.2`` the proxy doesn't recognise. + """ + + @pytest.mark.parametrize("model,expected", [ + ("ollama/glm-5.2", "ollama/glm-5.2"), + ("ollama/llama3.2", "ollama/llama3.2"), + ("custom/some-model", "some-model"), + ]) + def test_only_literal_custom_prefix_is_stripped(self, model, expected): + assert normalize_model_for_provider(model, "custom") == expected + + # ── detect_vendor ────────────────────────────────────────────────────── class TestDetectVendor: From a5ea9a6fd4d9e23fa99b27f796d23eba05a8a235 Mon Sep 17 00:00:00 2001 From: homelab-ha-agent <ha-agent@homelab.4410.us> Date: Wed, 1 Jul 2026 23:31:30 -0400 Subject: [PATCH 64/71] fix(model): don't misroute named custom providers to openrouter on save _normalize_main_model_assignment() (POST /api/model/set, the endpoint Desktop's Settings -> Model page uses to persist the main model slot) has a fallback for a specific analytics bug: an older session row with no billing_provider sends the model's bare vendor prefix as "provider" (e.g. "anthropic" from "anthropic/claude-opus-4.6"), so the code detects an unrecognized provider paired with a slash-bearing model and treats it as that stray-vendor-prefix case. Named custom providers are represented as "custom:<name>" slugs everywhere else in the codebase (runtime_provider.py, model_switch.py), but _KNOWN_PROVIDER_NAMES only lists the bare "custom" bucket. So picking a named custom provider (e.g. "custom:litellm", a LiteLLM proxy fronting Ollama) together with a slash-bearing model ("ollama/glm-5.2") looked identical to the stray-vendor-prefix case and got silently rewritten to provider: openrouter in config.yaml on save -- reassigning the provider entirely, not just mangling the model id. Exclude anything starting with "custom" from the fallback, matching the guard the same function already applies later for the actual normalize_model_for_provider call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- hermes_cli/web_server.py | 19 +++++++- .../test_normalize_main_model_assignment.py | 43 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_normalize_main_model_assignment.py diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 5b136bd820e6..d720111509e6 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1472,6 +1472,14 @@ def _normalize_main_model_assignment(provider: str, model: str) -> tuple[str, st known but ``poolside`` isn't) but the model is a vendor-prefixed aggregator slug, keep the user's CURRENT aggregator if they're on one, else fall back to openrouter. + + Named custom providers (``custom:litellm``, etc.) are excluded from + this fallback: ``_KNOWN_PROVIDER_NAMES`` only lists the bare + ``"custom"`` bucket, never a specific ``custom:<name>`` slug, so + without this exclusion every named custom provider paired with a + slash-bearing model (e.g. ``ollama/glm-5.2`` behind a LiteLLM proxy) + looked exactly like the stray-vendor-prefix case above and got + silently reassigned to ``openrouter``. 2. Model-format normalization for the resolved provider via ``normalize_model_for_provider`` (e.g. ``anthropic/claude-opus-4.6`` on native anthropic → ``claude-opus-4-6``). @@ -1506,7 +1514,16 @@ def _normalize_main_model_assignment(provider: str, model: str) -> tuple[str, st if custom_provider is not None: return custom_provider.id, model_in - if canonical not in _KNOWN_PROVIDER_NAMES and "/" in model_in: + # A named custom provider that didn't resolve above (typo, config + # mismatch, entry missing from custom_providers/providers) must still + # not be treated as a stray vendor prefix -- it isn't a known Hermes + # provider/alias, but it also isn't the analytics-vendor case this + # fallback exists for. + if ( + canonical not in _KNOWN_PROVIDER_NAMES + and not canonical.startswith("custom") + and "/" in model_in + ): # Vendor prefix posing as a provider (analytics fallback). Resolve # against the user's current provider when it's an aggregator that # serves vendor-prefixed slugs; otherwise default to openrouter. diff --git a/tests/hermes_cli/test_normalize_main_model_assignment.py b/tests/hermes_cli/test_normalize_main_model_assignment.py new file mode 100644 index 000000000000..830875ca9863 --- /dev/null +++ b/tests/hermes_cli/test_normalize_main_model_assignment.py @@ -0,0 +1,43 @@ +"""Regression tests for ``_normalize_main_model_assignment`` (POST /api/model/set). + +Named custom providers are represented as ``custom:<name>`` slugs everywhere +else in the codebase (``runtime_provider.py``, ``model_switch.py``), but +``_KNOWN_PROVIDER_NAMES`` only lists the bare ``"custom"`` bucket. Before this +fix, persisting a main-slot assignment for a named custom provider (e.g. a +LiteLLM proxy fronting Ollama, registered as ``custom:litellm``) together with +a slash-bearing model id (``ollama/glm-5.2``) was indistinguishable from the +"vendor prefix posing as a provider" analytics-fallback case, and got silently +rewritten to ``provider: openrouter`` in ``config.yaml`` -- reassigning the +provider entirely, not just mangling the model id. +""" + +from hermes_cli.web_server import _normalize_main_model_assignment + + +class TestNamedCustomProviderIsNotTreatedAsStrayVendorPrefix: + def test_named_custom_provider_slug_is_preserved(self): + assert _normalize_main_model_assignment("custom:litellm", "ollama/glm-5.2") == ( + "custom:litellm", + "ollama/glm-5.2", + ) + + def test_bare_custom_bucket_is_preserved(self): + assert _normalize_main_model_assignment("custom", "ollama/glm-5.2") == ( + "custom", + "ollama/glm-5.2", + ) + + +class TestStrayVendorPrefixFallbackStillWorks: + """The original bug this function fixes: an analytics row with no + ``billing_provider`` falls back to the model's vendor prefix as the + "provider" (e.g. ``provider="anthropic"`` from + ``modelVendor("anthropic/claude-opus-4.6")``). That must still resolve + to the native provider with its model normalized -- unaffected by the + ``custom:`` exclusion above. + """ + + def test_known_native_provider_still_normalizes_model(self): + assert _normalize_main_model_assignment( + "anthropic", "anthropic/claude-opus-4.6" + ) == ("anthropic", "claude-opus-4-6") From 1cd5f52b3e65557a6b230124b1527cdc5ab6af15 Mon Sep 17 00:00:00 2001 From: homelab-ha-agent <ha-agent@homelab.4410.us> Date: Wed, 15 Jul 2026 17:33:14 -0400 Subject: [PATCH 65/71] fix(model): narrow custom-provider fallback exclusion to real custom: syntax Per hermes-sweeper review on #56671: the fallback exclusion matched any canonical string starting with "custom" (e.g. "customproxy"), not just the durable named-custom-provider syntax ("custom" bucket or "custom:<name>" slugs). Narrow it to an exact/prefix match on that syntax so unrelated vendor names aren't accidentally exempted from the openrouter fallback. Also clarifies the test suite: a properly configured custom:<name> provider now resolves via resolve_custom_provider before this fallback is ever reached (added upstream in 9a15fad0d6), so the existing test was mislabeled as exercising that primary path when it was actually exercising the fallback-safety-net case (missing/unresolved config entry). Split into explicit primary-path and fallback-safety-net tests, plus a regression test for the "customproxy"-style false positive. --- hermes_cli/web_server.py | 9 ++- .../test_normalize_main_model_assignment.py | 79 ++++++++++++++++--- 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d720111509e6..4c0b105da046 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1518,10 +1518,15 @@ def _normalize_main_model_assignment(provider: str, model: str) -> tuple[str, st # mismatch, entry missing from custom_providers/providers) must still # not be treated as a stray vendor prefix -- it isn't a known Hermes # provider/alias, but it also isn't the analytics-vendor case this - # fallback exists for. + # fallback exists for. Match only the durable named-custom syntax + # (bare "custom" bucket, or "custom:<name>" per + # ``providers.custom_provider_slug``) -- a bare ``startswith("custom")`` + # would also swallow unrelated unconfigured vendor names that merely + # happen to start with "custom" (e.g. "customproxy"). + is_custom_provider_slug = canonical == "custom" or canonical.startswith("custom:") if ( canonical not in _KNOWN_PROVIDER_NAMES - and not canonical.startswith("custom") + and not is_custom_provider_slug and "/" in model_in ): # Vendor prefix posing as a provider (analytics fallback). Resolve diff --git a/tests/hermes_cli/test_normalize_main_model_assignment.py b/tests/hermes_cli/test_normalize_main_model_assignment.py index 830875ca9863..2d1e6e99b705 100644 --- a/tests/hermes_cli/test_normalize_main_model_assignment.py +++ b/tests/hermes_cli/test_normalize_main_model_assignment.py @@ -9,23 +9,82 @@ a slash-bearing model id (``ollama/glm-5.2``) was indistinguishable from the "vendor prefix posing as a provider" analytics-fallback case, and got silently rewritten to ``provider: openrouter`` in ``config.yaml`` -- reassigning the provider entirely, not just mangling the model id. + +A properly *configured* ``custom:<name>`` provider is now resolved earlier, +via ``resolve_custom_provider`` against ``custom_providers``/``providers`` in +config -- that's the primary, intended path and isn't this module's concern +to re-test. What's tested here is the fallback safety net for when that +resolution comes up empty (typo, config drift, entry removed) -- a +``custom:<name>`` slug must still not be misread as a stray analytics vendor +prefix and reassigned to openrouter, even though it isn't in +``_KNOWN_PROVIDER_NAMES`` either. """ +from unittest.mock import patch + from hermes_cli.web_server import _normalize_main_model_assignment -class TestNamedCustomProviderIsNotTreatedAsStrayVendorPrefix: - def test_named_custom_provider_slug_is_preserved(self): - assert _normalize_main_model_assignment("custom:litellm", "ollama/glm-5.2") == ( - "custom:litellm", - "ollama/glm-5.2", - ) +def _no_custom_providers_configured(): + """Patch load_config so resolve_user_provider/resolve_custom_provider + both come up empty, forcing execution into the fallback path under + test -- independent of whatever config.yaml happens to be on disk.""" + return patch("hermes_cli.web_server.load_config", return_value={}) + + +class TestUnresolvedNamedCustomProviderIsNotTreatedAsStrayVendorPrefix: + """Covers the case where ``resolve_custom_provider`` finds no match -- + e.g. ``custom:litellm`` was configured once, then the entry was renamed + or dropped from ``custom_providers``, but old sessions/config still + reference the old slug. + """ + + def test_unresolved_named_custom_provider_slug_is_preserved(self): + with _no_custom_providers_configured(): + assert _normalize_main_model_assignment("custom:litellm", "ollama/glm-5.2") == ( + "custom:litellm", + "ollama/glm-5.2", + ) def test_bare_custom_bucket_is_preserved(self): - assert _normalize_main_model_assignment("custom", "ollama/glm-5.2") == ( - "custom", - "ollama/glm-5.2", - ) + with _no_custom_providers_configured(): + assert _normalize_main_model_assignment("custom", "ollama/glm-5.2") == ( + "custom", + "ollama/glm-5.2", + ) + + def test_unconfigured_non_custom_vendor_name_still_falls_back(self): + """A name that merely starts with the substring "custom" but isn't + the durable ``custom:<name>`` syntax (no colon) is NOT exempted -- + it's just another unknown vendor label and should still hit the + openrouter fallback like any other unrecognized provider string. + """ + with _no_custom_providers_configured(): + assert _normalize_main_model_assignment( + "customproxy", "anthropic/claude-opus-4.6" + ) == ("openrouter", "anthropic/claude-opus-4.6") + + +class TestConfiguredNamedCustomProviderResolvesViaPrimaryPath: + """The primary, intended path: a ``custom:<name>`` slug that IS present + in ``custom_providers`` resolves through ``resolve_custom_provider`` + before the fallback under test above is ever reached. + """ + + def test_configured_named_custom_provider_resolves(self): + cfg = { + "custom_providers": [ + { + "name": "litellm", + "base_url": "http://localhost:4000/v1", + "key_env": "LITELLM_API_KEY", + } + ] + } + with patch("hermes_cli.web_server.load_config", return_value=cfg): + assert _normalize_main_model_assignment( + "custom:litellm", "ollama/glm-5.2" + ) == ("custom:litellm", "ollama/glm-5.2") class TestStrayVendorPrefixFallbackStillWorks: From e0a9a114668e63db821271ea27ea83003e6ac0f0 Mon Sep 17 00:00:00 2001 From: xxxigm <tuancanhnguyen706@gmail.com> Date: Mon, 27 Jul 2026 18:57:04 +0700 Subject: [PATCH 66/71] fix(compression): adopt durable history when the session grows pre-lease Busy sessions (memory review / shared session writers) kept outrunning the in-memory snapshot, so rotation-mode compress aborted every attempt with "changed before lease acquisition" and surfaced as a fake "No changes from compression". Adopt the durable transcript and continue compressing instead of returning the stale snapshot unchanged. --- agent/conversation_compression.py | 30 +++++++++++++------ .../agent/test_compression_concurrent_fork.py | 29 +++++++++++++----- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index d2df6f25a39f..09df0c66c4d0 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -1402,7 +1402,10 @@ def compress_context( # `name #N` renumber, no contextvar/env/logging re-sync, no memory/context- # engine session-switch. The conversation keeps one durable id for life, # eliminating the session-rotation bug cluster. Default True (2107b86024). - in_place = bool(getattr(agent, "compression_in_place", False)) + # Default True matches DEFAULT_CONFIG / #38763. A missing attribute must + # NOT fall back to rotation mode — that re-enables the pre-lease drift + # path and can wedge busy sessions that never set the flag. + in_place = bool(getattr(agent, "compression_in_place", True)) # Set True once the in-place DB write actually completes (the DB block can # raise and skip it). Surfaced to the gateway via agent._last_compaction_in_place. compacted_in_place = False @@ -1712,6 +1715,13 @@ def compress_context( # non-destructive — pre-compaction rows are soft-archived (active=0, # compacted=1), stay searchable and recoverable, so snapshot/durable # drift cannot lose data there and must not abort compaction. + # + # When durable DID grow, ADOPT it and continue rather than aborting. + # Aborting returned the stale snapshot unchanged, so busy sessions + # (memory review / shared session_id writers) stayed permanently + # behind the DB: every /compress and auto-compress saw + # "changed before lease acquisition", surfaced as the misleading + # "No changes from compression", and never reclaimed tokens. if not in_place and _lock_db is not None and _lock_sid: durable_loader = getattr( type(_lock_db), "get_messages_as_conversation", None @@ -1719,16 +1729,18 @@ def compress_context( if callable(durable_loader): durable_parent = durable_loader(_lock_db, _lock_sid) if isinstance(durable_parent, list) and len(durable_parent) > len(messages): - logger.warning( - "compression aborted: session=%s changed before lease " - "acquisition; preserving newer durable messages", + logger.info( + "compression: session=%s grew before lease " + "(%d → %d msgs); adopting durable snapshot", _lock_sid, + len(messages), + len(durable_parent), ) - _release_lock() - existing_prompt = getattr(agent, "_cached_system_prompt", None) - if not existing_prompt: - existing_prompt = agent._build_system_prompt(system_message) - return messages, existing_prompt + messages = durable_parent + # Token estimate was for the stale snapshot; clear it so + # the compressor re-derives from the adopted transcript + # instead of under-counting the newly visible rows. + approx_tokens = 0 # Notify external memory provider before compression discards context. # The provider's on_pre_compress() may return a string of insights it diff --git a/tests/agent/test_compression_concurrent_fork.py b/tests/agent/test_compression_concurrent_fork.py index b7c696ecb75b..bc20dfcb7afa 100644 --- a/tests/agent/test_compression_concurrent_fork.py +++ b/tests/agent/test_compression_concurrent_fork.py @@ -340,10 +340,17 @@ def test_concurrent_compression_does_not_fork_session(tmp_path: Path) -> None: ) -def test_durable_message_committed_before_lease_aborts_stale_snapshot( +def test_durable_message_committed_before_lease_is_adopted( tmp_path: Path, ) -> None: - """A durable row absent from the caller snapshot must survive in the parent.""" + """A durable row absent from the caller snapshot must still be compressed. + + Previously this path aborted and returned the stale snapshot unchanged, + which permanently wedged busy sessions: every compress attempt saw the + DB ahead of the in-memory list, logged "changed before lease + acquisition", and never called the compressor. Adopting the durable + transcript keeps the late-committed turn and lets compression proceed. + """ db = SessionDB(db_path=tmp_path / "state.db") parent_sid = "PRE_LEASE_DURABLE_RACE" db.create_session(parent_sid, source="webui") @@ -359,15 +366,21 @@ def test_durable_message_committed_before_lease_aborts_stale_snapshot( stale_snapshot, "sys", approx_tokens=120_000 ) - assert returned is stale_snapshot - assert agent.session_id == parent_sid - assert db.find_live_compression_child(parent_sid) is None - assert [m["content"] for m in db.get_messages_as_conversation(parent_sid)] == [ + agent.context_compressor.compress.assert_called_once() + compressed_arg = agent.context_compressor.compress.call_args.args[0] + assert [m["content"] for m in compressed_arg] == [ "old durable", "late committed before lease", ] - agent.context_compressor.compress.assert_not_called() - + # Must not echo the stale snapshot — compression proceeded on the + # adopted durable transcript (rotation publishes a child session). + assert returned is not stale_snapshot + assert returned[0]["content"] == "[CONTEXT COMPACTION] summary" + assert agent.session_id != parent_sid + child = db.find_live_compression_child(parent_sid) + assert child is not None + child_id = child["id"] if isinstance(child, dict) else child + assert child_id == agent.session_id def test_skipped_compression_returns_messages_unchanged(tmp_path: Path) -> None: """The loser of the lock race must return its input messages verbatim. From 74ae2d3bf2438e58dec5b78ee7b2207de31f0226 Mon Sep 17 00:00:00 2001 From: xxxigm <tuancanhnguyen706@gmail.com> Date: Mon, 27 Jul 2026 18:57:05 +0700 Subject: [PATCH 67/71] fix(compression): default in_place to True to match DEFAULT_CONFIG is_truthy_value(..., default=False) and getattr(..., False) disagreed with compression.in_place: true from #38763, so partial/failed config loads fell back into rotation mode and re-armed the pre-lease drift path. Also report compression.in_place in hermes dump overrides so stale false values are visible. --- agent/agent_init.py | 6 +++++- hermes_cli/dump.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 69d814f47dcc..95161f8cdee5 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1952,8 +1952,12 @@ def init_agent( # parent_session_id chain, no `name #N` renumber). See #38763 and # agent/conversation_compression.py. Consumed by compress_context(), not the # compressor, so it rides on the agent. + # Default True must match DEFAULT_CONFIG["compression"]["in_place"] + # (#38763). default=False here previously flipped agents into rotation + # mode whenever the merged config omitted the key (partial configs, + # load_config failure → {}), re-arming the pre-lease drift abort. compression_in_place = is_truthy_value( - _compression_cfg.get("in_place"), default=False + _compression_cfg.get("in_place"), default=True ) codex_app_server_auto_compaction = str( _compression_cfg.get("codex_app_server_auto", "native") or "native" diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py index 9b6685ae5e7c..e8ed12963e69 100644 --- a/hermes_cli/dump.py +++ b/hermes_cli/dump.py @@ -243,6 +243,7 @@ def _config_overrides(config: dict) -> dict[str, str]: ("browser", "allow_private_urls"), ("compression", "enabled"), ("compression", "threshold"), + ("compression", "in_place"), ("display", "streaming"), ("display", "skin"), ("display", "show_reasoning"), From 8eaaa5021c098544044cae3dd546f0a011104c1a Mon Sep 17 00:00:00 2001 From: kshitij <kshitijkapoor0611@gmail.com> Date: Mon, 27 Jul 2026 18:51:25 +0500 Subject: [PATCH 68/71] fix(compression): update _pre_msg_count after durable adoption Update _pre_msg_count after adopting the durable transcript so the post-compression log reflects the correct pre-adoption message count. Also use the existing _live_child_id() helper in the updated test instead of hand-rolled child-id extraction. Follow-up to #72631. --- agent/conversation_compression.py | 1 + contributors/emails/kshitijkapoor0611@gmail.com | 1 + tests/agent/test_compression_concurrent_fork.py | 5 ++--- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 contributors/emails/kshitijkapoor0611@gmail.com diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 09df0c66c4d0..0f56f9a92c43 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -1737,6 +1737,7 @@ def compress_context( len(durable_parent), ) messages = durable_parent + _pre_msg_count = len(messages) # Token estimate was for the stale snapshot; clear it so # the compressor re-derives from the adopted transcript # instead of under-counting the newly visible rows. diff --git a/contributors/emails/kshitijkapoor0611@gmail.com b/contributors/emails/kshitijkapoor0611@gmail.com new file mode 100644 index 000000000000..c7510483c049 --- /dev/null +++ b/contributors/emails/kshitijkapoor0611@gmail.com @@ -0,0 +1 @@ +kshitijk4poor diff --git a/tests/agent/test_compression_concurrent_fork.py b/tests/agent/test_compression_concurrent_fork.py index bc20dfcb7afa..6e0360c51575 100644 --- a/tests/agent/test_compression_concurrent_fork.py +++ b/tests/agent/test_compression_concurrent_fork.py @@ -377,9 +377,8 @@ def test_durable_message_committed_before_lease_is_adopted( assert returned is not stale_snapshot assert returned[0]["content"] == "[CONTEXT COMPACTION] summary" assert agent.session_id != parent_sid - child = db.find_live_compression_child(parent_sid) - assert child is not None - child_id = child["id"] if isinstance(child, dict) else child + child_id = _live_child_id(db, parent_sid) + assert child_id is not None assert child_id == agent.session_id def test_skipped_compression_returns_messages_unchanged(tmp_path: Path) -> None: From c92417e77f8799d606b2fde45ff27ef979ce8cba Mon Sep 17 00:00:00 2001 From: alelpoan <155192176+alelpoan@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:41:37 +0300 Subject: [PATCH 69/71] fix(desktop): Branch button silently does nothing inside a branched chat tile (#71969) * fix: Branch button is a dead no-op inside a branched chat tile session-tile.tsx wired onBranchInNewChat to () => undefined for tiled/branched sessions (nested branching isn't supported there), but the button in AssistantMessage's action bar rendered unconditionally regardless of whether a real handler was supplied. The button looked clickable but silently did nothing, with no visual feedback. - AssistantMessage now only renders the Branch button when onBranchInNewChat is actually provided, matching the existing pattern used for onDismissError/onRestoreToMessage. - session-tile.tsx no longer passes a no-op handler; the prop is simply omitted so the button doesn't render in tiles. - onBranchInNewChat is now optional on ChatViewProps, and the latestChatActions passthrough wrapper uses the existing latestOptional helper instead of an unconditional call. * test: assert Branch button visibility matches handler presence Adds coverage for the bug #2 fix: renders Thread with and without an onBranchInNewChat handler and asserts the Branch in new chat button is shown only when a real handler is supplied, hidden otherwise - covering both the normal open-chat case and the session-tile (branched chat) case that used to leave a dead, clickable button. --- apps/desktop/src/app/chat/index.tsx | 2 +- apps/desktop/src/app/chat/session-tile.tsx | 1 - .../desktop/src/app/contrib/latest-actions.ts | 2 +- .../thread/assistant-message.test.tsx | 90 +++++++++++++++++++ .../assistant-ui/thread/assistant-message.tsx | 20 +++-- 5 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 5fdf5a0487b1..40a8922d453f 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -71,7 +71,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> { onCancel: () => Promise<void> | void onAddContextRef: (refText: string, label?: string, detail?: string) => void onAddUrl: (url: string) => void - onBranchInNewChat: (messageId: string) => void + onBranchInNewChat?: (messageId: string) => void maxVoiceRecordingSeconds?: number onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index dde25d3d2019..ae618e344e6c 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -170,7 +170,6 @@ function TileChat({ onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} onAttachDroppedItems={composer.attachDroppedItems} onAttachImageBlob={composer.attachImageBlob} - onBranchInNewChat={() => undefined} onCancel={actions.cancelRun} onDeleteSelectedSession={() => undefined} onDismissError={actions.dismissError} diff --git a/apps/desktop/src/app/contrib/latest-actions.ts b/apps/desktop/src/app/contrib/latest-actions.ts index 4407739398a9..7d191208a27c 100644 --- a/apps/desktop/src/app/contrib/latest-actions.ts +++ b/apps/desktop/src/app/contrib/latest-actions.ts @@ -34,7 +34,7 @@ export function latestChatActions(actions: ChatActions): ChatActions { onAddUrl: (...args) => actions.onAddUrl(...args), onAttachDroppedItems: (...args) => actions.onAttachDroppedItems(...args), onAttachImageBlob: (...args) => actions.onAttachImageBlob(...args), - onBranchInNewChat: (...args) => actions.onBranchInNewChat(...args), + onBranchInNewChat: latestOptional(() => actions.onBranchInNewChat), onCancel: (...args) => actions.onCancel(...args), onDeleteSelectedSession: (...args) => actions.onDeleteSelectedSession(...args), onDismissError: latestOptional(() => actions.onDismissError), diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx new file mode 100644 index 000000000000..8fbe4fc23b90 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx @@ -0,0 +1,90 @@ +// Bug #2: the Branch-in-new-chat button used to render unconditionally even +// when its handler was a no-op (session-tile.tsx passed `() => undefined` +// for branched/tiled chats, where nested branching isn't supported). That +// left a visibly clickable button that silently did nothing. The fix makes +// AssistantMessage's action bar hide the button entirely when no handler is +// supplied, matching how onDismissError/onRestoreToMessage already behave. +import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { Thread } from '.' + +const createdAt = new Date('2026-05-01T00:00:00.000Z') + +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', TestResizeObserver) +vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0) +) +vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) +vi.stubGlobal('CSS', { escape: (str: string) => str }) +Element.prototype.scrollTo = function scrollTo() {} + +afterEach(() => { + cleanup() +}) + +function userMessage(): ThreadMessage { + return { + id: 'user-1', + role: 'user', + content: [{ type: 'text', text: 'question one' }], + attachments: [], + createdAt, + metadata: { custom: {} } + } as ThreadMessage +} + +function assistantMessage(): ThreadMessage { + return { + id: 'assistant-1', + role: 'assistant', + content: [{ type: 'text', text: 'done' }], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function Harness({ onBranchInNewChat }: { onBranchInNewChat?: (messageId: string) => void }) { + const runtime = useExternalStoreRuntime<ThreadMessage>({ + messages: [userMessage(), assistantMessage()], + isRunning: false, + onNew: async () => {} + }) + return ( + <AssistantRuntimeProvider runtime={runtime}> + <Thread onBranchInNewChat={onBranchInNewChat} /> + </AssistantRuntimeProvider> + ) +} + +describe('AssistantMessage branch button visibility (bug #2 fix)', () => { + it('shows the Branch in new chat button when a handler is provided (open chat)', async () => { + render(<Harness onBranchInNewChat={() => undefined} />) + + expect(await screen.findByRole('button', { name: 'Branch in new chat' })).toBeTruthy() + }) + + it('hides the Branch in new chat button when no handler is provided (session-tile / branched chat)', async () => { + render(<Harness />) + + // Wait for the assistant message to actually mount before asserting + // absence, so a missing button isn't just a false negative from an + // unrendered message. + await screen.findByText('done') + + expect(screen.queryByRole('button', { name: 'Branch in new chat' })).toBeNull() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index 8593ce104de3..3b7d0c8310eb 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -151,15 +151,17 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText, data-slot="aui_msg-actions" > <MessageAge /> - <TooltipIconButton - onClick={() => { - triggerHaptic('selection') - onBranchInNewChat?.(messageId) - }} - tooltip={copy.branchNewChat} - > - <GitForkIcon className="size-3.5" /> - </TooltipIconButton> + {onBranchInNewChat && ( + <TooltipIconButton + onClick={() => { + triggerHaptic('selection') + onBranchInNewChat(messageId) + }} + tooltip={copy.branchNewChat} + > + <GitForkIcon className="size-3.5" /> + </TooltipIconButton> + )} <CopyButton appearance="icon" buttonSize="icon" label={copy.copy} text={getMessageText} /> <ReadAloudButton getText={getMessageText} messageId={messageId} /> <ActionBarPrimitive.Reload asChild> From 820a8083d47d371049cb344a33f2ee22a7570a3a Mon Sep 17 00:00:00 2001 From: alelpoan <155192176+alelpoan@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:45:00 +0300 Subject: [PATCH 70/71] fix(desktop): Branch in new chat drops the question and loses the branched session on restart (#71960) * fix: Branch in new chat loses the question and the branched session (#issue) - session.branch on the backend now accepts a count param to truncate the parent history to the clicked message, instead of always forking the entire transcript. Also returns stored_session_id/messages/info so the frontend has parity with session.create's response shape. - branchCurrentSession (open live chat) now slices history from 0 instead of from the clicked message index, so the question preceding an assistant reply is no longer dropped when branching. - forkBranch now calls session.branch (not session.create) when branching an open live chat, since session.create only persists a DB row lazily on first prompt - a branched chat that nobody types into never got saved, and vanished as 'session not found' on the next app restart. branchStoredSession (branching from the sidebar, no live runtime) keeps using session.create as before. * test: cover session.branch count truncation and open-chat branching - backend: assert session.branch with a count param only persists the first N messages of the live history to the new session. - frontend: BranchHarness now exposes branchCurrentSession; assert branching an open chat from a middle message calls session.branch with the parent session id and the correct trimmed count, instead of session.create. --- .../hooks/use-session-actions.test.tsx | 58 +++++++++++++++- .../hooks/use-session-actions/index.ts | 34 ++++++--- tests/tui_gateway/test_protocol.py | 69 +++++++++++++++++++ tui_gateway/server.py | 17 ++++- 4 files changed, 163 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 603b45a1a2bd..ddf6571f5511 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -977,19 +977,23 @@ describe('resumeSession failure recovery', () => { }) function BranchHarness({ + activeSessionId = null, navigate = vi.fn(), + onCurrentReady, onReady, requestGateway }: { + activeSessionId?: string | null navigate?: ReturnType<typeof vi.fn> + onCurrentReady?: (branchCurrentSession: (messageId?: string) => Promise<boolean>) => void onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> }) { const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value }) const actions = useSessionActions({ - activeSessionId: null, - activeSessionIdRef: ref<string | null>(null), + activeSessionId, + activeSessionIdRef: ref<string | null>(activeSessionId), busyRef: ref(false), creatingSessionRef: ref(false), ensureSessionState: () => ({}) as ClientSessionState, @@ -1008,7 +1012,8 @@ function BranchHarness({ useEffect(() => { onReady(actions.branchStoredSession) - }, [actions.branchStoredSession, onReady]) + onCurrentReady?.(actions.branchCurrentSession) + }, [actions.branchCurrentSession, actions.branchStoredSession, onCurrentReady, onReady]) return null } @@ -1090,6 +1095,53 @@ describe('branchStoredSession desktop source tagging', () => { }) }) + it('branches an open live chat via session.branch with a trimmed message count (bug #1/#3 fix)', async () => { + let branchParams: Record<string, unknown> | undefined + const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { + if (method === 'session.branch') { + branchParams = params + return { + session_id: 'branch-runtime', + stored_session_id: 'branch-stored', + title: 'Branch', + message_count: 2, + messages: [], + info: {} + } as never + } + return {} as never + }) + + setMessages([ + { id: 'q1', role: 'user', parts: [{ type: 'text', text: 'question one' }] }, + { id: 'a1', role: 'assistant', parts: [{ type: 'text', text: 'answer one' }] }, + { id: 'q2', role: 'user', parts: [{ type: 'text', text: 'question two' }] }, + { id: 'a2', role: 'assistant', parts: [{ type: 'text', text: 'answer two' }] } + ]) + + let branchCurrentSession: ((messageId?: string) => Promise<boolean>) | null = null + render( + <BranchHarness + activeSessionId="live-parent" + onCurrentReady={branch => (branchCurrentSession = branch)} + onReady={() => undefined} + requestGateway={requestGateway} + /> + ) + await waitFor(() => expect(branchCurrentSession).not.toBeNull()) + + // Branch from the FIRST assistant reply ("a1"), not the last message + // this is exactly the scenario that used to drop the question (bug #1): + // only the clicked message survived instead of everything up to it. + await expect(branchCurrentSession!('a1')).resolves.toBe(true) + + expect(requestGateway).toHaveBeenCalledWith('session.branch', { + session_id: 'live-parent', + count: 2 + }) + expect(branchParams).toEqual({ session_id: 'live-parent', count: 2 }) + }) + // #67603: right-clicking a session outside the paginated sidebar window is a // cache miss. Resolve its owning profile (cache → active → cross-profile) and // swap to it before reading the transcript / creating the branch, so the fork diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 4dbcfa6000ef..fd490666d126 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -1102,6 +1102,7 @@ export function useSessionActions({ const forkBranch = useCallback( async ( branchMessages: BranchMessage[], + sourceSessionId: null | string, parentStoredId: null | string, cwd?: string, profile?: null | string @@ -1119,14 +1120,19 @@ export function useSessionActions({ await ensureGatewayProfile(profile) // No title: the backend auto-names the branch from its parent's lineage. - const branched = await requestGateway<SessionCreateResponse>('session.create', { - cols: 96, - source: 'desktop', - ...(cwd && { cwd }), - ...(profile ? { profile } : {}), - messages: branchMessages.map(({ content, role }) => ({ content, role })), - ...(parentStoredId && { parent_session_id: parentStoredId }) - }) + const branched = sourceSessionId + ? await requestGateway<SessionCreateResponse>('session.branch', { + session_id: sourceSessionId, + count: branchMessages.length + }) + : await requestGateway<SessionCreateResponse>('session.create', { + cols: 96, + source: 'desktop', + ...(cwd && { cwd }), + ...(profile ? { profile } : {}), + messages: branchMessages.map(({ content, role }) => ({ content, role })), + ...(parentStoredId && { parent_session_id: parentStoredId }) + }) const routedSessionId = branched.stored_session_id ?? branched.session_id const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null @@ -1212,7 +1218,7 @@ export function useSessionActions({ ? messages.findIndex(message => message.id === messageId) : messages.findLastIndex(message => message.role === 'assistant' || message.role === 'user') - const start = at >= 0 ? at : Math.max(messages.length - 1, 0) + const start = 0 const end = at >= 0 ? at + 1 : messages.length const branchMessages = toBranchMessages(messages.slice(start, end)) @@ -1229,7 +1235,13 @@ export function useSessionActions({ // must stay on that thread's backend (cache hit for an open session). const profile = await resolveSessionProfile(selectedStoredSessionIdRef.current) - return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim(), profile) + return forkBranch( + branchMessages, + activeSessionIdRef.current, + selectedStoredSessionIdRef.current, + $currentCwd.get().trim(), + profile + ) }, [activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef] ) @@ -1261,7 +1273,7 @@ export function useSessionActions({ return false } - return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim(), profile) + return await forkBranch(branchMessages, null, stored?.id ?? storedSessionId, stored?.cwd?.trim(), profile) } catch (err) { notifyError(err, copy.branchFailed) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index de2e6c39b014..a32fea2768fe 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -1289,6 +1289,75 @@ def test_session_branch_persists_branched_from_marker(server, monkeypatch): assert kwargs["model_config"] == {"_branched_from": parent_key} +def test_session_branch_with_count_truncates_history(server, monkeypatch): + """Branch-from-a-specific-message support (issue: Branch in new chat + loses the question): the desktop client passes ``count`` to keep only + the first N messages of the parent's live history - everything after + the clicked message must NOT be copied into the branch. + """ + append_calls = [] + + class _DB: + def get_session_title(self, _key): + return "parent-title" + + def get_next_title_in_lineage(self, base): + return f"{base} 2" + + def create_session(self, new_key, **kwargs): + return new_key + + def append_message(self, **kwargs): + append_calls.append(kwargs) + return None + + def set_session_title(self, _key, _title): + return None + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + monkeypatch.setattr(server, "_resolve_model", lambda: "test/model") + monkeypatch.setattr(server, "_new_session_key", lambda: "20260101_000001_child0") + monkeypatch.setattr( + server, + "_make_agent", + lambda _sid, key, session_id=None, session_db=None, **_kwargs: types.SimpleNamespace( + model="test/model", session_id=session_id or key + ), + ) + monkeypatch.setattr(server, "_init_session", lambda *_a, **_k: None) + monkeypatch.setattr(server, "_set_session_context", lambda *_a, **_k: []) + monkeypatch.setattr(server, "_clear_session_context", lambda *_a, **_k: None) + monkeypatch.setattr(server, "_session_cwd", lambda _s: "/tmp/branch-cwd") + + parent_sid = "parent01" + parent_key = "20260101_000000_parent" + server._sessions[parent_sid] = { + "session_key": parent_key, + "history": [ + {"role": "user", "content": "question one"}, + {"role": "assistant", "content": "answer one"}, + {"role": "user", "content": "question two"}, + {"role": "assistant", "content": "answer two"}, + ], + "history_lock": threading.Lock(), + "cols": 80, + } + + resp = server.handle_request( + { + "id": "b1", + "method": "session.branch", + "params": {"session_id": parent_sid, "count": 2}, + } + ) + + assert "error" not in resp, resp + assert len(append_calls) == 2 + assert append_calls[0]["content"] == "question one" + assert append_calls[1]["content"] == "answer one" + assert resp["result"]["message_count"] == 2 + + def test_session_branch_forwards_original_timestamps(server, monkeypatch): """TUI /branch must copy the parent's messages WITH their original timestamps — append_message otherwise stamps time.time() at INSERT and diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 95461910856a..863601d68eb3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -10312,6 +10312,9 @@ def _(rid, params: dict) -> dict: history = [dict(msg) for msg in session.get("history", [])] if not history: return _err(rid, 4008, "nothing to branch — send a message first") + count = params.get("count") + if isinstance(count, int) and count > 0: + history = history[:count] new_key = _new_session_key() new_sid = uuid.uuid4().hex[:8] source = _session_source(session) @@ -10415,7 +10418,19 @@ def _(rid, params: dict) -> dict: if lease is not None: lease.release() return _err(rid, 5000, f"agent init failed on branch: {e}") - return _ok(rid, {"session_id": new_sid, "title": title, "parent": old_key}) + branched_session = _sessions.get(new_sid) + return _ok( + rid, + { + "session_id": new_sid, + "stored_session_id": new_key, + "title": title, + "parent": old_key, + "message_count": len(history), + "messages": _history_to_messages(history), + "info": _session_info(agent, branched_session), + }, + ) @method("session.interrupt") From 846b14ab01a84483d2c3dd429579173040474585 Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:52:58 +0000 Subject: [PATCH 71/71] fmt(js): `npm run fix` on merge (#72703) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> --- .../src/app/session/hooks/use-session-actions.test.tsx | 5 ++++- .../assistant-ui/thread/assistant-message.test.tsx | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index ddf6571f5511..531937b64b83 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -1097,9 +1097,11 @@ describe('branchStoredSession desktop source tagging', () => { it('branches an open live chat via session.branch with a trimmed message count (bug #1/#3 fix)', async () => { let branchParams: Record<string, unknown> | undefined + const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { if (method === 'session.branch') { branchParams = params + return { session_id: 'branch-runtime', stored_session_id: 'branch-stored', @@ -1109,6 +1111,7 @@ describe('branchStoredSession desktop source tagging', () => { info: {} } as never } + return {} as never }) @@ -1130,7 +1133,7 @@ describe('branchStoredSession desktop source tagging', () => { ) await waitFor(() => expect(branchCurrentSession).not.toBeNull()) - // Branch from the FIRST assistant reply ("a1"), not the last message + // Branch from the FIRST assistant reply ("a1"), not the last message � // this is exactly the scenario that used to drop the question (bug #1): // only the clicked message survived instead of everything up to it. await expect(branchCurrentSession!('a1')).resolves.toBe(true) diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx index 8fbe4fc23b90..2f39c2a2eb59 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx @@ -23,6 +23,7 @@ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => ) vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) vi.stubGlobal('CSS', { escape: (str: string) => str }) + Element.prototype.scrollTo = function scrollTo() {} afterEach(() => { @@ -63,6 +64,7 @@ function Harness({ onBranchInNewChat }: { onBranchInNewChat?: (messageId: string isRunning: false, onNew: async () => {} }) + return ( <AssistantRuntimeProvider runtime={runtime}> <Thread onBranchInNewChat={onBranchInNewChat} />