diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml index 95477ccf01..86e7ae477b 100644 --- a/.github/workflows/upload_to_pypi.yml +++ b/.github/workflows/upload_to_pypi.yml @@ -71,10 +71,11 @@ jobs: test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; } test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; } - - name: Bundle install.sh into wheel + - name: Bundle install scripts into wheel run: | mkdir -p hermes_cli/scripts cp scripts/install.sh hermes_cli/scripts/install.sh + cp scripts/install.ps1 hermes_cli/scripts/install.ps1 - name: Build wheel and sdist run: uv build --sdist --wheel diff --git a/Dockerfile b/Dockerfile index bde3412ed7..6e8f020963 100644 --- a/Dockerfile +++ b/Dockerfile @@ -115,5 +115,6 @@ RUN uv pip install --no-cache-dir --no-deps -e "." ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist ENV HERMES_HOME=/opt/data ENV PATH="/opt/data/.local/bin:${PATH}" +RUN mkdir -p /opt/data VOLUME [ "/opt/data" ] ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/opt/hermes/docker/entrypoint.sh" ] diff --git a/acp_adapter/bootstrap/__init__.py b/acp_adapter/bootstrap/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 b/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 deleted file mode 100644 index f840fd2d55..0000000000 --- a/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 +++ /dev/null @@ -1,288 +0,0 @@ -# bootstrap_browser_tools.ps1 — install agent-browser + Playwright Chromium -# into ~/.hermes/node/ for use by Hermes Agent's browser tools on Windows. -# -# Targets the registry-install path: users who got Hermes via -# `uvx --from 'hermes-agent[acp]==X' hermes-acp` don't have a repo clone, -# so the install.ps1 `npm install`-in-repo flow doesn't apply. This script -# is a self-contained, idempotent slice of install.ps1's browser block. -# -# Usage: -# .\bootstrap_browser_tools.ps1 # use defaults -# .\bootstrap_browser_tools.ps1 -Yes # accept Chromium download -# .\bootstrap_browser_tools.ps1 -SkipChromium # Node + agent-browser only -# -# Idempotent: re-running this is safe and fast. - -[CmdletBinding()] -param( - [switch]$Yes, - [switch]$SkipChromium -) - -$ErrorActionPreference = "Stop" -$NodeVersion = "22" - -# ───────────────────────────────────────────────────────────────────────── -# Logging -# ───────────────────────────────────────────────────────────────────────── - -function Write-Info { param([string]$msg) Write-Host "[*] $msg" -ForegroundColor Cyan } -function Write-Success { param([string]$msg) Write-Host "[+] $msg" -ForegroundColor Green } -function Write-Warn { param([string]$msg) Write-Host "[!] $msg" -ForegroundColor Yellow } -function Write-Err { param([string]$msg) Write-Host "[x] $msg" -ForegroundColor Red } - -# ───────────────────────────────────────────────────────────────────────── -# Paths -# ───────────────────────────────────────────────────────────────────────── - -$HermesHome = $env:HERMES_HOME -if (-not $HermesHome) { - $HermesHome = Join-Path $env:USERPROFILE ".hermes" -} -$NodePrefix = Join-Path $HermesHome "node" - -# ───────────────────────────────────────────────────────────────────────── -# Step 1: Node.js -# ───────────────────────────────────────────────────────────────────────── - -function Resolve-NpmExe { - # Same gotcha as install.ps1: prefer npm.cmd over npm.ps1 so the - # PowerShell execution policy doesn't block us. - $cmd = Get-Command npm -ErrorAction SilentlyContinue - if (-not $cmd) { return $null } - $npmExe = $cmd.Source - if ($npmExe -like "*.ps1") { - $sibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd" - if (Test-Path $sibling) { return $sibling } - } - return $npmExe -} - -function Resolve-NpxExe { - $cmd = Get-Command npx -ErrorAction SilentlyContinue - if (-not $cmd) { return $null } - $npxExe = $cmd.Source - if ($npxExe -like "*.ps1") { - $sibling = Join-Path (Split-Path $npxExe -Parent) "npx.cmd" - if (Test-Path $sibling) { return $sibling } - } - return $npxExe -} - -function Ensure-Node { - # System Node on PATH? - $sysNode = Get-Command node -ErrorAction SilentlyContinue - if ($sysNode) { - try { - $v = & $sysNode.Source --version - $major = [int]($v -replace '^v(\d+).*', '$1') - if ($major -ge 20) { - Write-Success "Node.js $v found on PATH" - return - } - Write-Warn "Node.js $v is older than v20 — installing managed Node." - } catch { - Write-Warn "Failed to query Node version: $_" - } - } - - # Hermes-managed Node? - $managedNode = Join-Path $NodePrefix "node.exe" - if (Test-Path $managedNode) { - $v = & $managedNode --version - Write-Success "Node.js $v found (Hermes-managed at $NodePrefix)" - # Prepend to current-process PATH so subsequent npm/npx calls find it. - $env:PATH = "$NodePrefix;$env:PATH" - return - } - - Write-Info "Installing Node.js $NodeVersion LTS into $NodePrefix ..." - - $arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" } - $indexUrl = "https://nodejs.org/dist/latest-v${NodeVersion}.x/" - - try { - $indexPage = Invoke-WebRequest -Uri $indexUrl -UseBasicParsing - $matches = [regex]::Matches($indexPage.Content, "node-v${NodeVersion}\.\d+\.\d+-win-${arch}\.zip") - if ($matches.Count -eq 0) { - Write-Err "Could not locate Node.js $NodeVersion zip for win-$arch" - throw "no tarball" - } - $zipName = $matches[0].Value - $zipUrl = "$indexUrl$zipName" - - $tmpDir = Join-Path $env:TEMP "hermes-node-$([guid]::NewGuid().ToString('N'))" - New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null - $zipPath = Join-Path $tmpDir $zipName - - Write-Info "Downloading $zipName ..." - Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing - - Expand-Archive -Path $zipPath -DestinationPath $tmpDir -Force - $extracted = Get-ChildItem -Path $tmpDir -Directory | Where-Object { $_.Name -like "node-v*" } | Select-Object -First 1 - - if (-not $extracted) { Write-Err "Node.js extraction failed"; throw "extract" } - - if (Test-Path $NodePrefix) { Remove-Item -Recurse -Force $NodePrefix } - New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null - Move-Item -Path $extracted.FullName -Destination $NodePrefix - - Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue - - $env:PATH = "$NodePrefix;$env:PATH" - $v = & "$NodePrefix\node.exe" --version - Write-Success "Node.js $v installed to $NodePrefix" - } catch { - Write-Err "Node.js install failed: $_" - Write-Info "Install Node 20+ manually from https://nodejs.org/en/download/ and re-run." - throw - } -} - -# ───────────────────────────────────────────────────────────────────────── -# Step 2: agent-browser -# ───────────────────────────────────────────────────────────────────────── - -function Ensure-AgentBrowser { - $npmExe = Resolve-NpmExe - if (-not $npmExe) { - Write-Err "npm not on PATH after Node install — aborting" - throw "npm missing" - } - - # Already installed? - $existing = Get-Command agent-browser -ErrorAction SilentlyContinue - if ($existing) { - Write-Success "agent-browser already installed at $($existing.Source)" - return - } - - # When the user has system Node (winget / installer-based), `npm install - # -g` writes to a directory that may require admin rights. Force the - # prefix to the user-writable Hermes-managed Node directory so we never - # need elevation and the agent can always find the result. Mirrors the - # bash bootstrap's `--prefix $NODE_PREFIX` strategy. - New-Item -ItemType Directory -Force -Path $NodePrefix | Out-Null - - Write-Info "Installing agent-browser (npm, prefix=$NodePrefix)..." - & $npmExe install -g --prefix $NodePrefix --silent ` - "agent-browser@^0.26.0" "@askjo/camofox-browser@^1.5.2" - if ($LASTEXITCODE -ne 0) { - Write-Err "npm install -g agent-browser failed (exit $LASTEXITCODE)" - throw "npm install" - } - - # Windows npm global installs drop shims at $NodePrefix\ root (not bin/). - # Prepend to PATH so any subsequent npx call resolves them. - $env:PATH = "$NodePrefix;$env:PATH" - - Write-Success "agent-browser installed to $NodePrefix" -} - -# ───────────────────────────────────────────────────────────────────────── -# Step 3: Playwright Chromium -# ───────────────────────────────────────────────────────────────────────── - -function Find-SystemBrowser { - $candidates = @( - "C:\Program Files\Google\Chrome\Application\chrome.exe", - "C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", - "C:\Program Files\Chromium\Application\chromium.exe", - "${env:LOCALAPPDATA}\Google\Chrome\Application\chrome.exe", - "${env:LOCALAPPDATA}\Chromium\Application\chromium.exe" - ) - foreach ($p in $candidates) { - if (Test-Path $p) { return $p } - } - # Edge — Chromium-based, agent-browser can use it - foreach ($p in @( - "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", - "C:\Program Files\Microsoft\Edge\Application\msedge.exe" - )) { - if (Test-Path $p) { return $p } - } - return $null -} - -function Write-BrowserEnv { - param([string]$BrowserPath) - $envFile = Join-Path $HermesHome ".env" - New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null - if (Test-Path $envFile) { - $existing = Get-Content $envFile -Raw -ErrorAction SilentlyContinue - if ($existing -and ($existing -match "(?m)^AGENT_BROWSER_EXECUTABLE_PATH=")) { - return - } - } - Add-Content -Path $envFile -Value "" - Add-Content -Path $envFile -Value "# Hermes Agent browser tools — use the system Chrome/Chromium/Edge binary." - Add-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" - Write-Success "Configured browser tools to use $BrowserPath" -} - -function Confirm-ChromiumDownload { - if ($Yes) { return $true } - if (-not [Environment]::UserInteractive) { - Write-Warn "Non-interactive shell — skipping Chromium prompt." - Write-Info "Re-run with -Yes to install Chromium (~400 MB download)." - return $false - } - $reply = Read-Host "Install Playwright Chromium (~400 MB download)? [y/N]" - return ($reply -match "^(y|yes)$") -} - -function Ensure-Chromium { - if ($SkipChromium) { - Write-Info "Skipping Chromium install (-SkipChromium)" - return - } - - # agent-browser on Windows expects a Playwright-managed Chromium under - # %LOCALAPPDATA%\ms-playwright. The system-browser shortcut from the - # Linux/macOS path doesn't apply the same way on Windows — Playwright's - # default launch path won't pick up a stock Chrome install without an - # explicit AGENT_BROWSER_EXECUTABLE_PATH. We still offer it as a - # fallback when the user doesn't want the download. - - if (-not (Confirm-ChromiumDownload)) { - $sys = Find-SystemBrowser - if ($sys) { - Write-Info "Using system browser at $sys (Chromium download skipped)." - Write-BrowserEnv -BrowserPath $sys - } else { - Write-Info "Chromium install skipped. Browser tools won't launch until" - Write-Info "Chromium is installed or AGENT_BROWSER_EXECUTABLE_PATH is set." - } - return - } - - $npxExe = Resolve-NpxExe - if (-not $npxExe) { - Write-Err "npx not on PATH — cannot install Playwright Chromium" - throw "npx missing" - } - - Write-Info "Installing Playwright Chromium (~400 MB) ..." - & $npxExe --yes playwright install chromium - if ($LASTEXITCODE -ne 0) { - Write-Err "Playwright Chromium install failed (exit $LASTEXITCODE)" - Write-Info "Try again later: npx --yes playwright install chromium" - throw "playwright" - } - Write-Success "Playwright Chromium installed" -} - -# ───────────────────────────────────────────────────────────────────────── -# Main -# ───────────────────────────────────────────────────────────────────────── - -Write-Info "Hermes Agent: bootstrapping browser tools" -Write-Info " HERMES_HOME = $HermesHome" -Write-Info " OS = Windows" - -Ensure-Node -Ensure-AgentBrowser -Ensure-Chromium - -Write-Success "Browser tools setup complete." -Write-Info "Hermes Agent will pick up agent-browser from $NodePrefix on next launch." diff --git a/acp_adapter/bootstrap/bootstrap_browser_tools.sh b/acp_adapter/bootstrap/bootstrap_browser_tools.sh deleted file mode 100755 index 9981069a6a..0000000000 --- a/acp_adapter/bootstrap/bootstrap_browser_tools.sh +++ /dev/null @@ -1,399 +0,0 @@ -#!/usr/bin/env bash -# -# bootstrap_browser_tools.sh — install agent-browser + Playwright Chromium -# into ~/.hermes/node/ for use by Hermes Agent's browser tools. -# -# Targets the registry-install path: users who got Hermes via -# `uvx --from 'hermes-agent[acp]==X' hermes-acp` don't have a repo clone, -# so the install.sh `npm install`-in-repo flow doesn't apply. This script -# is a self-contained, idempotent slice of install.sh's browser block — -# safe to run from `hermes-acp --setup-browser`, from a fresh terminal, -# or from install.sh itself (it's a no-op when everything is already in place). -# -# Usage: -# bootstrap_browser_tools.sh # use defaults -# bootstrap_browser_tools.sh --yes # accept the ~400MB Chromium download -# bootstrap_browser_tools.sh --skip-chromium # only install Node + agent-browser -# HERMES_HOME=/custom/path bootstrap_browser_tools.sh -# -# Idempotent: re-running this is safe and fast. Each step checks whether -# the work is already done. - -set -euo pipefail - -# ───────────────────────────────────────────────────────────────────────── -# Config -# ───────────────────────────────────────────────────────────────────────── - -NODE_VERSION="22" -HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" -NODE_PREFIX="$HERMES_HOME/node" - -SKIP_CHROMIUM=false -ASSUME_YES=false - -# ───────────────────────────────────────────────────────────────────────── -# Logging -# ───────────────────────────────────────────────────────────────────────── - -if [ -t 1 ]; then - C_GREEN='\033[0;32m' - C_YELLOW='\033[0;33m' - C_BLUE='\033[0;34m' - C_RED='\033[0;31m' - C_RESET='\033[0m' -else - C_GREEN='' ; C_YELLOW='' ; C_BLUE='' ; C_RED='' ; C_RESET='' -fi - -log_info() { printf "${C_BLUE}[*]${C_RESET} %s\n" "$*"; } -log_success() { printf "${C_GREEN}[✓]${C_RESET} %s\n" "$*"; } -log_warn() { printf "${C_YELLOW}[!]${C_RESET} %s\n" "$*" >&2; } -log_error() { printf "${C_RED}[✗]${C_RESET} %s\n" "$*" >&2; } - -# ───────────────────────────────────────────────────────────────────────── -# Arg parsing -# ───────────────────────────────────────────────────────────────────────── - -while [ $# -gt 0 ]; do - case "$1" in - --skip-chromium) SKIP_CHROMIUM=true ;; - --yes|-y) ASSUME_YES=true ;; - -h|--help) - cat </dev/null 2>&1; then - local found_ver major - found_ver=$(node --version 2>/dev/null) - major=$(echo "$found_ver" | sed -E 's/^v([0-9]+).*/\1/') - if [ -n "$major" ] && [ "$major" -ge 20 ]; then - log_success "Node.js $found_ver found on PATH" - return 0 - fi - log_warn "Node.js $found_ver is older than v20 — installing managed Node." - fi - - if [ -x "$NODE_PREFIX/bin/node" ]; then - local found_ver - found_ver=$("$NODE_PREFIX/bin/node" --version 2>/dev/null || echo "?") - export PATH="$NODE_PREFIX/bin:$PATH" - log_success "Node.js $found_ver found (Hermes-managed at $NODE_PREFIX)" - return 0 - fi - - log_info "Installing Node.js $NODE_VERSION LTS into $NODE_PREFIX ..." - - local index_url="https://nodejs.org/dist/latest-v${NODE_VERSION}.x/" - local tarball_name - tarball_name=$(curl -fsSL "$index_url" \ - | grep -oE "node-v${NODE_VERSION}\.[0-9]+\.[0-9]+-${NODE_OS}-${NODE_ARCH}\.tar\.xz" \ - | head -1) - - if [ -z "$tarball_name" ]; then - tarball_name=$(curl -fsSL "$index_url" \ - | grep -oE "node-v${NODE_VERSION}\.[0-9]+\.[0-9]+-${NODE_OS}-${NODE_ARCH}\.tar\.gz" \ - | head -1) - fi - - if [ -z "$tarball_name" ]; then - log_error "Could not locate Node.js $NODE_VERSION tarball for $NODE_OS-$NODE_ARCH" - log_info "Install Node 20+ manually: https://nodejs.org/en/download/" - return 1 - fi - - local tmp_dir - tmp_dir=$(mktemp -d) - trap 'rm -rf "$tmp_dir"' RETURN - - log_info "Downloading $tarball_name ..." - if ! curl -fsSL "${index_url}${tarball_name}" -o "$tmp_dir/$tarball_name"; then - log_error "Node.js download failed" - return 1 - fi - - if [[ "$tarball_name" == *.tar.xz ]]; then - tar xf "$tmp_dir/$tarball_name" -C "$tmp_dir" - else - tar xzf "$tmp_dir/$tarball_name" -C "$tmp_dir" - fi - - local extracted_dir - extracted_dir=$(ls -d "$tmp_dir"/node-v* 2>/dev/null | head -1) - if [ ! -d "$extracted_dir" ]; then - log_error "Node.js extraction failed" - return 1 - fi - - mkdir -p "$HERMES_HOME" - rm -rf "$NODE_PREFIX" - mv "$extracted_dir" "$NODE_PREFIX" - - export PATH="$NODE_PREFIX/bin:$PATH" - - local installed_ver - installed_ver=$("$NODE_PREFIX/bin/node" --version 2>/dev/null || echo "?") - log_success "Node.js $installed_ver installed to $NODE_PREFIX" -} - -# ───────────────────────────────────────────────────────────────────────── -# Step 2: agent-browser + @askjo/camofox-browser via global npm install -# ───────────────────────────────────────────────────────────────────────── - -ensure_agent_browser() { - if ! command -v npm >/dev/null 2>&1; then - log_error "npm not on PATH after Node install — aborting" - return 1 - fi - - # _find_agent_browser() in tools/browser_tool.py walks ~/.hermes/node/bin - # plus a few standard prefixes, so installing globally into the managed - # Node prefix is enough — no PATH manipulation needed from the agent side. - if [ -x "$NODE_PREFIX/bin/agent-browser" ] || command -v agent-browser >/dev/null 2>&1; then - log_success "agent-browser already installed" - return 0 - fi - - # When the system's `npm` resolves to a root-owned prefix (e.g. - # /usr/lib/node_modules), `npm install -g` fails with EACCES without - # sudo. Force the prefix to the user-writable Hermes-managed Node - # directory so we never need sudo and the agent can always find the - # result. If we installed Node ourselves above, this is a no-op - # (managed Node already uses $NODE_PREFIX). If the user has system - # Node, we still drop agent-browser under $NODE_PREFIX/bin/ — which - # is exactly where _browser_candidate_path_dirs() looks first. - mkdir -p "$NODE_PREFIX" - - log_info "Installing agent-browser (npm, prefix=$NODE_PREFIX)..." - if ! npm install -g --prefix "$NODE_PREFIX" --silent \ - agent-browser@^0.26.0 \ - "@askjo/camofox-browser@^1.5.2"; then - log_error "npm install -g agent-browser failed" - return 1 - fi - - # macOS/Linux global installs place the shim into $NODE_PREFIX/bin/. - # Add it to PATH for any subsequent steps (npx playwright). - export PATH="$NODE_PREFIX/bin:$PATH" - - log_success "agent-browser installed to $NODE_PREFIX/bin/" -} - -# ───────────────────────────────────────────────────────────────────────── -# Step 3: Playwright Chromium -# ───────────────────────────────────────────────────────────────────────── - -confirm_chromium_download() { - if [ "$ASSUME_YES" = true ]; then return 0; fi - if [ ! -t 0 ]; then - log_warn "Non-interactive shell — skipping Chromium prompt." - log_info "Re-run with --yes to install Chromium (~400 MB download)." - return 1 - fi - printf "Install Playwright Chromium (~400 MB download)? [y/N] " - local reply="" - read -r reply || reply="" - case "$reply" in - y|Y|yes|YES) return 0 ;; - *) return 1 ;; - esac -} - -# Detect a usable system Chrome/Chromium. agent-browser's Chrome engine can -# use it instead of downloading Playwright's bundled Chromium, saving the -# download cost. Returns the path or empty string. -find_system_browser() { - local candidate - for candidate in google-chrome google-chrome-stable chromium chromium-browser chrome; do - if command -v "$candidate" >/dev/null 2>&1; then - command -v "$candidate" - return 0 - fi - done - # macOS app-bundle locations - if [ "$OS" = "macos" ]; then - for candidate in \ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ - "/Applications/Chromium.app/Contents/MacOS/Chromium" ; do - if [ -x "$candidate" ]; then - echo "$candidate" - return 0 - fi - done - fi - return 1 -} - -write_browser_env() { - local browser_path="$1" - local env_file="$HERMES_HOME/.env" - mkdir -p "$HERMES_HOME" - if [ -f "$env_file" ] && grep -q "^AGENT_BROWSER_EXECUTABLE_PATH=" "$env_file"; then - return 0 - fi - { - echo "" - echo "# Hermes Agent browser tools — use the system Chrome/Chromium binary." - echo "AGENT_BROWSER_EXECUTABLE_PATH=$browser_path" - } >> "$env_file" - log_success "Configured browser tools to use $browser_path" -} - -ensure_chromium() { - if [ "$SKIP_CHROMIUM" = true ]; then - log_info "Skipping Chromium install (--skip-chromium)" - return 0 - fi - - local system_browser - system_browser="$(find_system_browser 2>/dev/null || true)" - if [ -n "$system_browser" ]; then - log_success "Found system browser: $system_browser" - log_info "Skipping Playwright Chromium download; agent-browser will use it." - write_browser_env "$system_browser" - return 0 - fi - - if ! confirm_chromium_download; then - log_info "Chromium install skipped. Browser tools will only work if you" - log_info "set AGENT_BROWSER_EXECUTABLE_PATH or install Chromium later." - return 0 - fi - - if ! command -v npx >/dev/null 2>&1; then - log_error "npx not on PATH — cannot install Playwright Chromium" - return 1 - fi - - log_info "Installing Playwright Chromium (~400 MB) ..." - - # On apt-based distros, --with-deps requires sudo. Try non-interactively - # only — never prompt — and fall back to the bare browser-only install. - local installed=false - if [ "$OS" = "linux" ]; then - case "$DISTRO" in - ubuntu|debian|raspbian|pop|linuxmint|elementary|zorin|kali|parrot) - if [ "$(id -u)" -eq 0 ] || (command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null); then - log_info "Installing system deps with --with-deps (sudo available)" - if npx --yes playwright install --with-deps chromium; then - installed=true - fi - else - log_warn "sudo not available non-interactively — installing Chromium without system deps." - log_info "If browser tools fail to launch, an administrator should run:" - log_info " sudo npx playwright install-deps chromium" - fi - ;; - arch|manjaro|cachyos|endeavouros|garuda) - log_info "Arch-family system dependencies are not auto-installed." - log_info "If launch fails, run: sudo pacman -S nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib" - ;; - fedora|rhel|centos|rocky|alma) - log_info "Fedora/RHEL system dependencies are not auto-installed." - log_info "If launch fails, run: sudo dnf install nss atk at-spi2-core cups-libs libdrm libxkbcommon mesa-libgbm pango cairo alsa-lib" - ;; - opensuse*|sles) - log_info "openSUSE system dependencies are not auto-installed." - ;; - esac - fi - - if [ "$installed" = false ]; then - if npx --yes playwright install chromium; then - installed=true - fi - fi - - if [ "$installed" = true ]; then - log_success "Playwright Chromium installed" - else - log_error "Playwright Chromium install failed" - log_info "Try again later: npx --yes playwright install chromium" - return 1 - fi -} - -# ───────────────────────────────────────────────────────────────────────── -# Main -# ───────────────────────────────────────────────────────────────────────── - -main() { - log_info "Hermes Agent: bootstrapping browser tools" - log_info " HERMES_HOME = $HERMES_HOME" - log_info " OS / arch = $NODE_OS-$NODE_ARCH ${DISTRO:+($DISTRO)}" - - ensure_node - ensure_agent_browser - ensure_chromium - - log_success "Browser tools setup complete." - log_info "Hermes Agent will pick up agent-browser from $NODE_PREFIX/bin/ on next launch." -} - -main diff --git a/acp_adapter/edit_approval.py b/acp_adapter/edit_approval.py new file mode 100644 index 0000000000..7c5fcaefd2 --- /dev/null +++ b/acp_adapter/edit_approval.py @@ -0,0 +1,278 @@ +"""Pre-execution ACP edit approval helpers. + +This module is intentionally isolated from the generic tool registry. ACP binds +an edit approval requester in a ContextVar for the duration of one ACP agent run; +CLI, gateway, and other sessions leave it unset and therefore bypass this guard. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from concurrent.futures import TimeoutError as FutureTimeout +from contextvars import ContextVar, Token +from dataclasses import dataclass +from itertools import count +from pathlib import Path +from typing import Any, Callable + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class EditProposal: + """A proposed single-file edit that can be shown to an ACP client.""" + + tool_name: str + path: str + old_text: str | None + new_text: str + arguments: dict[str, Any] + + +EditApprovalRequester = Callable[[EditProposal], bool] + +_EDIT_APPROVAL_REQUESTER: ContextVar[EditApprovalRequester | None] = ContextVar( + "ACP_EDIT_APPROVAL_REQUESTER", + default=None, +) +_PERMISSION_REQUEST_IDS = count(1) + + +SENSITIVE_AUTO_APPROVE_NAMES = {".env", ".env.local", ".env.production", "id_rsa", "id_ed25519"} +AUTO_APPROVE_ASK = "ask" +AUTO_APPROVE_WORKSPACE = "workspace_session" +AUTO_APPROVE_SESSION = "session" + + +def set_edit_approval_requester(requester: EditApprovalRequester | None) -> Token: + """Bind an ACP edit approval requester for the current context.""" + + return _EDIT_APPROVAL_REQUESTER.set(requester) + + +def reset_edit_approval_requester(token: Token) -> None: + """Restore a previous edit approval requester binding.""" + + _EDIT_APPROVAL_REQUESTER.reset(token) + + +def clear_edit_approval_requester() -> None: + """Clear the current requester; primarily used by tests.""" + + _EDIT_APPROVAL_REQUESTER.set(None) + + +def get_edit_approval_requester() -> EditApprovalRequester | None: + return _EDIT_APPROVAL_REQUESTER.get() + + +def _read_text_if_exists(path: str) -> str | None: + p = Path(path).expanduser() + if not p.exists(): + return None + if not p.is_file(): + raise OSError(f"Cannot edit non-file path: {path}") + return p.read_text(encoding="utf-8", errors="replace") + + +def _proposal_for_write_file(arguments: dict[str, Any]) -> EditProposal: + path = str(arguments.get("path") or "") + if not path: + raise ValueError("path required") + content = arguments.get("content") + if content is None: + raise ValueError("content required") + return EditProposal( + tool_name="write_file", + path=path, + old_text=_read_text_if_exists(path), + new_text=str(content), + arguments=dict(arguments), + ) + + +def _proposal_for_patch_replace(arguments: dict[str, Any]) -> EditProposal: + path = str(arguments.get("path") or "") + if not path: + raise ValueError("path required") + old_string = arguments.get("old_string") + new_string = arguments.get("new_string") + if old_string is None or new_string is None: + raise ValueError("old_string and new_string required") + + old_text = _read_text_if_exists(path) + if old_text is None: + raise ValueError(f"Failed to read file: {path}") + + from tools.fuzzy_match import fuzzy_find_and_replace + + new_text, match_count, _strategy, error = fuzzy_find_and_replace( + old_text, + str(old_string), + str(new_string), + bool(arguments.get("replace_all", False)), + ) + if error or match_count == 0: + raise ValueError(error or f"Could not find match for old_string in {path}") + + return EditProposal( + tool_name="patch", + path=path, + old_text=old_text, + new_text=new_text, + arguments=dict(arguments), + ) + + +def build_edit_proposal(tool_name: str, arguments: dict[str, Any]) -> EditProposal | None: + """Return an edit proposal for supported file mutation calls.""" + + if tool_name == "write_file": + return _proposal_for_write_file(arguments) + if tool_name == "patch" and arguments.get("mode", "replace") == "replace": + return _proposal_for_patch_replace(arguments) + return None + + +def _is_sensitive_auto_approve_path(path: str) -> bool: + parts = Path(path).expanduser().parts + lowered = {part.lower() for part in parts} + if ".git" in lowered or ".ssh" in lowered: + return True + return Path(path).name.lower() in SENSITIVE_AUTO_APPROVE_NAMES + + +def should_auto_approve_edit(proposal: EditProposal, policy: str, cwd: str | None = None) -> bool: + """Return whether an ACP edit proposal may bypass the prompt for this session. + + This is intentionally session-scoped and conservative: sensitive paths still + ask even under autonomous policies. + """ + + policy = str(policy or AUTO_APPROVE_ASK).strip() + if policy == AUTO_APPROVE_ASK or _is_sensitive_auto_approve_path(proposal.path): + return False + path = Path(proposal.path).expanduser().resolve(strict=False) + if policy == AUTO_APPROVE_SESSION: + return True + if policy == AUTO_APPROVE_WORKSPACE: + if str(path).startswith("/tmp/"): + return True + if cwd: + root = Path(cwd).expanduser().resolve(strict=False) + try: + path.relative_to(root) + return True + except ValueError: + return False + return False + + +def maybe_require_edit_approval(tool_name: str, arguments: dict[str, Any]) -> str | None: + """Run ACP edit approval if bound. + + Returns a JSON tool-error string when the edit must be blocked, otherwise + ``None`` so dispatch can continue. Requester exceptions deny by default. + """ + + requester = get_edit_approval_requester() + if requester is None: + return None + + try: + proposal = build_edit_proposal(tool_name, arguments) + except Exception as exc: + logger.warning("Could not build ACP edit approval proposal for %s: %s", tool_name, exc) + return json.dumps({"error": f"Edit approval denied: could not prepare diff ({exc})"}, ensure_ascii=False) + + if proposal is None: + return None + + try: + approved = bool(requester(proposal)) + except Exception as exc: + logger.warning("ACP edit approval requester failed: %s", exc) + approved = False + + if approved: + return None + return json.dumps({"error": "Edit approval denied by ACP client; file was not modified."}, ensure_ascii=False) + + +def build_acp_edit_tool_call(proposal: EditProposal): + """Build the ToolCallUpdate payload for ACP request_permission.""" + + import acp + + tool_call_id = f"edit-approval-{next(_PERMISSION_REQUEST_IDS)}" + return acp.update_tool_call( + tool_call_id, + title=f"Approve edit: {proposal.path}", + kind="edit", + status="pending", + content=[ + acp.tool_diff_content( + path=proposal.path, + old_text=proposal.old_text, + new_text=proposal.new_text, + ) + ], + raw_input={"tool": proposal.tool_name, "arguments": proposal.arguments}, + ) + + +def make_acp_edit_approval_requester( + request_permission_fn: Callable, + loop: asyncio.AbstractEventLoop, + session_id: str, + timeout: float = 60.0, + auto_approve_getter: Callable[[], tuple[str, str | None]] | None = None, +) -> EditApprovalRequester: + """Return a sync requester that bridges edit proposals to ACP permissions.""" + + def _requester(proposal: EditProposal) -> bool: + from acp.schema import PermissionOption + from agent.async_utils import safe_schedule_threadsafe + + if auto_approve_getter is not None: + try: + policy, cwd = auto_approve_getter() + if should_auto_approve_edit(proposal, policy, cwd): + logger.info("Auto-approved ACP edit under policy %s: %s", policy, proposal.path) + return True + except Exception: + logger.debug("ACP edit auto-approval policy check failed", exc_info=True) + + options = [ + PermissionOption(option_id="allow_once", kind="allow_once", name="Allow edit"), + PermissionOption(option_id="deny", kind="reject_once", name="Deny"), + ] + tool_call = build_acp_edit_tool_call(proposal) + coro = request_permission_fn( + session_id=session_id, + tool_call=tool_call, + options=options, + ) + future = safe_schedule_threadsafe( + coro, + loop, + logger=logger, + log_message="Edit approval request: failed to schedule on loop", + ) + if future is None: + return False + try: + response = future.result(timeout=timeout) + except (FutureTimeout, Exception) as exc: + future.cancel() + logger.warning("Edit approval request timed out or failed: %s", exc) + return False + outcome = getattr(response, "outcome", None) + return ( + getattr(outcome, "outcome", None) == "selected" + and getattr(outcome, "option_id", None) == "allow_once" + ) + + return _requester diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index cf5c2ba9cf..9ce6281824 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -182,56 +182,31 @@ def _run_setup() -> None: def _run_setup_browser(assume_yes: bool = False) -> int: - """Bootstrap agent-browser + Playwright Chromium for the registry-install path. + """Bootstrap agent-browser + Chromium. - Shells out to the bundled platform-specific bootstrap script - (acp_adapter/bootstrap/bootstrap_browser_tools.{sh,ps1}) so the install - logic lives in one place — readable, debuggable, and shareable with - install.sh / install.ps1 if we ever want to call it from there too. + Routes through dep_ensure -> install.{sh,ps1} --ensure, sharing code + with ``hermes postinstall`` and the runtime lazy installer. - Returns the script's exit code (0 on success). + Returns 0 on success, 1 on failure. """ - import platform - import subprocess + from hermes_cli.dep_ensure import ensure_dependency - bootstrap_dir = Path(__file__).resolve().parent / "bootstrap" - - if platform.system() == "Windows": - script = bootstrap_dir / "bootstrap_browser_tools.ps1" - if not script.is_file(): - print( - f"Bootstrap script not found at {script} — wheel may be incomplete.", - file=sys.stderr, - ) - return 1 - cmd = [ - "powershell.exe", - "-NoProfile", - "-ExecutionPolicy", "Bypass", - "-File", str(script), - ] - if assume_yes: - cmd.append("-Yes") - else: - script = bootstrap_dir / "bootstrap_browser_tools.sh" - if not script.is_file(): - print( - f"Bootstrap script not found at {script} — wheel may be incomplete.", - file=sys.stderr, - ) - return 1 - cmd = ["bash", str(script)] - if assume_yes: - cmd.append("--yes") - - # stdio is inherited so the user sees the bootstrap's progress live. try: - result = subprocess.run(cmd, check=False) - except FileNotFoundError as exc: - # bash / powershell.exe not on PATH - print(f"Could not launch browser bootstrap: {exc}", file=sys.stderr) + node_ok = ensure_dependency("node", interactive=not assume_yes) + if not node_ok: + print("Node.js installation failed — cannot proceed with browser tools.", + file=sys.stderr) + return 1 + + browser_ok = ensure_dependency("browser", interactive=not assume_yes) + if not browser_ok: + print("Browser tools installation failed.", file=sys.stderr) + return 1 + + return 0 + except OSError as exc: + print(f"Browser bootstrap failed: {exc}", file=sys.stderr) return 1 - return result.returncode def main(argv: list[str] | None = None) -> None: diff --git a/acp_adapter/events.py b/acp_adapter/events.py index 00e940b9ee..ab82c0e7e3 100644 --- a/acp_adapter/events.py +++ b/acp_adapter/events.py @@ -117,6 +117,7 @@ def make_tool_progress_cb( loop: asyncio.AbstractEventLoop, tool_call_ids: Dict[str, Deque[str]], tool_call_meta: Dict[str, Dict[str, Any]], + edit_approval_policy_getter: Callable[[], tuple[str, str | None]] | None = None, ) -> Callable: """Create a ``tool_progress_callback`` for AIAgent. @@ -162,7 +163,20 @@ def make_tool_progress_cb( logger.debug("Failed to capture ACP edit snapshot for %s", name, exc_info=True) tool_call_meta[tc_id] = {"args": args, "snapshot": snapshot} - update = build_tool_start(tc_id, name, args) + edit_diff = None + if name in {"write_file", "patch"} and edit_approval_policy_getter is not None: + try: + from acp_adapter.edit_approval import build_edit_proposal, should_auto_approve_edit + + proposal = build_edit_proposal(name, args) + if proposal is not None: + policy, cwd = edit_approval_policy_getter() + if should_auto_approve_edit(proposal, policy, cwd): + edit_diff = proposal + except Exception: + logger.debug("Failed to prepare auto-approved ACP edit diff for %s", name, exc_info=True) + + update = build_tool_start(tc_id, name, args, edit_diff=edit_diff) _send_update(conn, session_id, loop, update) return _tool_progress diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 3031de161f..e4fc336b66 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -47,6 +47,8 @@ from acp.schema import ( SessionCapabilities, SessionForkCapabilities, SessionListCapabilities, + SessionMode, + SessionModeState, SessionModelState, SessionResumeCapabilities, SessionInfo, @@ -495,6 +497,20 @@ class HermesACPAgent(acp.Agent): }, ) + _EDIT_APPROVAL_POLICY_CONFIG_ID = "edit_approval_policy" + _EDIT_APPROVAL_POLICY_DEFAULT = "ask" + _MODE_DEFAULT = "default" + _MODE_ACCEPT_EDITS = "accept_edits" + _MODE_DONT_ASK = "dont_ask" + _MODE_TO_EDIT_APPROVAL_POLICY = { + _MODE_DEFAULT: "ask", + _MODE_ACCEPT_EDITS: "workspace_session", + _MODE_DONT_ASK: "session", + } + _EDIT_APPROVAL_POLICY_TO_MODE = { + value: key for key, value in _MODE_TO_EDIT_APPROVAL_POLICY.items() + } + def __init__(self, session_manager: SessionManager | None = None): super().__init__() self.session_manager = session_manager or SessionManager() @@ -507,6 +523,45 @@ class HermesACPAgent(acp.Agent): self._conn = conn logger.info("ACP client connected") + + def _session_modes(self, state: SessionState) -> SessionModeState: + """Return ACP session modes while preserving Zed's separate model picker. + + Zed renders ``config_options`` in the prominent selector slot where the + model picker was visible. Claude/Codex expose policy-like controls as ACP + modes, which coexist with the model picker, so Hermes maps edit approval + policy onto modes instead of advertising config options. + """ + + current = str(getattr(state, "mode", "") or self._MODE_DEFAULT) + if current not in self._MODE_TO_EDIT_APPROVAL_POLICY: + current = self._MODE_DEFAULT + return SessionModeState( + current_mode_id=current, + available_modes=[ + SessionMode( + id=self._MODE_DEFAULT, + name="Default", + description="Ask before edits.", + ), + SessionMode( + id=self._MODE_ACCEPT_EDITS, + name="Accept Edits", + description="Auto-allow workspace and /tmp edits; still asks for sensitive paths.", + ), + SessionMode( + id=self._MODE_DONT_ASK, + name="Don't Ask", + description="Auto-allow file edits for this session except sensitive paths.", + ), + ], + ) + + def _edit_approval_policy_for_state(self, state: SessionState) -> tuple[str, str | None]: + mode = str(getattr(state, "mode", "") or self._MODE_DEFAULT) + policy = self._MODE_TO_EDIT_APPROVAL_POLICY.get(mode, self._EDIT_APPROVAL_POLICY_DEFAULT) + return policy, state.cwd + @staticmethod def _encode_model_choice(provider: str | None, model: str | None) -> str: """Encode a model selection so ACP clients can keep provider context.""" @@ -992,6 +1047,7 @@ class HermesACPAgent(acp.Agent): return NewSessionResponse( session_id=state.session_id, models=self._build_model_state(state), + modes=self._session_modes(state), ) async def load_session( @@ -1033,7 +1089,10 @@ class HermesACPAgent(acp.Agent): ) self._schedule_available_commands_update(session_id) self._schedule_usage_update(state) - return LoadSessionResponse(models=self._build_model_state(state)) + return LoadSessionResponse( + models=self._build_model_state(state), + modes=self._session_modes(state), + ) async def resume_session( self, @@ -1062,7 +1121,10 @@ class HermesACPAgent(acp.Agent): ) self._schedule_available_commands_update(state.session_id) self._schedule_usage_update(state) - return ResumeSessionResponse(models=self._build_model_state(state)) + return ResumeSessionResponse( + models=self._build_model_state(state), + modes=self._session_modes(state), + ) async def cancel(self, session_id: str, **kwargs: Any) -> None: state = self.session_manager.get_session(session_id) @@ -1092,7 +1154,11 @@ class HermesACPAgent(acp.Agent): logger.info("Forked session %s -> %s", session_id, new_id) if new_id: self._schedule_available_commands_update(new_id) - return ForkSessionResponse(session_id=new_id) + return ForkSessionResponse( + session_id=new_id, + models=self._build_model_state(state) if state is not None else None, + modes=self._session_modes(state) if state is not None else None, + ) async def list_sessions( self, @@ -1243,11 +1309,19 @@ class HermesACPAgent(acp.Agent): tool_call_ids: dict[str, Deque[str]] = defaultdict(deque) tool_call_meta: dict[str, dict[str, Any]] = {} previous_approval_cb = None + edit_approval_requester = None streamed_message = False if conn: - tool_progress_cb = make_tool_progress_cb(conn, session_id, loop, tool_call_ids, tool_call_meta) + tool_progress_cb = make_tool_progress_cb( + conn, + session_id, + loop, + tool_call_ids, + tool_call_meta, + edit_approval_policy_getter=lambda: self._edit_approval_policy_for_state(state), + ) reasoning_cb = make_thinking_cb(conn, session_id, loop) step_cb = make_step_cb(conn, session_id, loop, tool_call_ids, tool_call_meta) message_cb = make_message_cb(conn, session_id, loop) @@ -1259,6 +1333,17 @@ class HermesACPAgent(acp.Agent): message_cb(text) approval_cb = make_approval_callback(conn.request_permission, loop, session_id) + try: + from acp_adapter.edit_approval import make_acp_edit_approval_requester + + edit_approval_requester = make_acp_edit_approval_requester( + conn.request_permission, + loop, + session_id, + auto_approve_getter=lambda: self._edit_approval_policy_for_state(state), + ) + except Exception: + logger.debug("Could not create ACP edit approval requester", exc_info=True) else: tool_progress_cb = None reasoning_cb = None @@ -1288,9 +1373,10 @@ class HermesACPAgent(acp.Agent): # which requires a notify_cb registered in _gateway_notify_cbs. previous_approval_cb = None previous_interactive = None + edit_approval_token = None def _run_agent() -> dict: - nonlocal previous_approval_cb, previous_interactive + nonlocal previous_approval_cb, previous_interactive, edit_approval_token # Bind HERMES_SESSION_KEY for this session so per-session caches # (e.g. the interactive sudo password cache in tools.terminal_tool) # scope to the ACP session rather than leaking across sessions @@ -1314,6 +1400,13 @@ class HermesACPAgent(acp.Agent): _terminal_tool.set_approval_callback(approval_cb) except Exception: logger.debug("Could not set ACP approval callback", exc_info=True) + if edit_approval_requester: + try: + from acp_adapter.edit_approval import set_edit_approval_requester + + edit_approval_token = set_edit_approval_requester(edit_approval_requester) + except Exception: + logger.debug("Could not set ACP edit approval requester", exc_info=True) # Signal to tools.approval that we have an interactive callback # and the non-interactive auto-approve path must not fire. previous_interactive = os.environ.get("HERMES_INTERACTIVE") @@ -1341,6 +1434,13 @@ class HermesACPAgent(acp.Agent): _terminal_tool.set_approval_callback(previous_approval_cb) except Exception: logger.debug("Could not restore approval callback", exc_info=True) + if edit_approval_token is not None: + try: + from acp_adapter.edit_approval import reset_edit_approval_requester + + reset_edit_approval_requester(edit_approval_token) + except Exception: + logger.debug("Could not restore ACP edit approval requester", exc_info=True) if session_tokens is not None and clear_session_vars is not None: try: clear_session_vars(session_tokens) @@ -1763,9 +1863,12 @@ class HermesACPAgent(acp.Agent): if state is None: logger.warning("Session %s: mode switch requested for missing session", session_id) return None - setattr(state, "mode", mode_id) + normalized_mode = str(mode_id or "").strip() + if normalized_mode not in self._MODE_TO_EDIT_APPROVAL_POLICY: + normalized_mode = self._MODE_DEFAULT + setattr(state, "mode", normalized_mode) self.session_manager.save_session(session_id) - logger.info("Session %s: mode switched to %s", session_id, mode_id) + logger.info("Session %s: mode switched to %s", session_id, normalized_mode) return SetSessionModeResponse() async def set_config_option( @@ -1777,11 +1880,15 @@ class HermesACPAgent(acp.Agent): logger.warning("Session %s: config update requested for missing session", session_id) return None - options = getattr(state, "config_options", None) - if not isinstance(options, dict): - options = {} - options[str(config_id)] = value - setattr(state, "config_options", options) + if str(config_id) == self._EDIT_APPROVAL_POLICY_CONFIG_ID: + mode = self._EDIT_APPROVAL_POLICY_TO_MODE.get(str(value), self._MODE_DEFAULT) + setattr(state, "mode", mode) + else: + options = getattr(state, "config_options", None) + if not isinstance(options, dict): + options = {} + options[str(config_id)] = value + setattr(state, "config_options", options) self.session_manager.save_session(session_id) logger.info("Session %s: config option %s updated", session_id, config_id) return SetSessionConfigOptionResponse(config_options=[]) diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index 77a62e243b..6513f1bb55 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -895,7 +895,7 @@ def _build_tool_complete_content( if len(display_result) > 5000: display_result = display_result[:4900] + f"\n... ({len(result)} chars total, truncated)" - if tool_name in {"write_file", "patch", "skill_manage"}: + if tool_name == "skill_manage": try: from agent.display import extract_edit_diff @@ -928,6 +928,8 @@ def build_tool_start( tool_call_id: str, tool_name: str, arguments: Dict[str, Any], + *, + edit_diff: Any = None, ) -> ToolCallStart: """Create a ToolCallStart event for the given hermes tool invocation.""" kind = get_tool_kind(tool_name) @@ -935,23 +937,34 @@ def build_tool_start( locations = extract_locations(arguments) if tool_name == "patch": - mode = arguments.get("mode", "replace") - if mode == "replace": - path = arguments.get("path", "") - old = arguments.get("old_string", "") - new = arguments.get("new_string", "") - content = [acp.tool_diff_content(path=path, new_text=new, old_text=old)] + if edit_diff is not None: + content = [ + acp.tool_diff_content( + path=edit_diff.path, + old_text=edit_diff.old_text, + new_text=edit_diff.new_text, + ) + ] else: - patch_text = arguments.get("patch", "") - content = _build_patch_mode_content(patch_text) + mode = arguments.get("mode", "replace") + path = arguments.get("path") or "patch input" + content = [_text(f"Preparing {mode} edit for {path}. Approval prompt shows the diff.")] return acp.start_tool_call( tool_call_id, title, kind=kind, content=content, locations=locations, ) if tool_name == "write_file": - path = arguments.get("path", "") - file_content = arguments.get("content", "") - content = [acp.tool_diff_content(path=path, new_text=file_content)] + if edit_diff is not None: + content = [ + acp.tool_diff_content( + path=edit_diff.path, + old_text=edit_diff.old_text, + new_text=edit_diff.new_text, + ) + ] + else: + path = arguments.get("path", "") + content = [_text(f"Preparing write to {path}. Approval prompt shows the diff." if path else "Preparing file write. Approval prompt shows the diff.")] return acp.start_tool_call( tool_call_id, title, kind=kind, content=content, locations=locations, ) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index e7e1a8acb6..de9b7dd586 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -471,14 +471,18 @@ def _requires_bearer_auth(base_url: str | None) -> bool: """Return True for Anthropic-compatible providers that require Bearer auth. Some third-party /anthropic endpoints implement Anthropic's Messages API but - require Authorization: Bearer *** of Anthropic's native x-api-key header. - MiniMax's global and China Anthropic-compatible endpoints follow this pattern. + require Authorization: Bearer instead of Anthropic's native x-api-key header. + MiniMax's global and China Anthropic-compatible endpoints, and Azure AI + Foundry's Anthropic-style endpoint follow this pattern. """ normalized = _normalize_base_url_text(base_url) if not normalized: return False normalized = normalized.rstrip("/").lower() - return normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic")) + return ( + normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic")) + or "azure.com" in normalized + ) def _base_url_needs_context_1m_beta(base_url: str | None) -> bool: @@ -489,6 +493,21 @@ def _base_url_needs_context_1m_beta(base_url: str | None) -> bool: return "azure.com" in normalized +def _is_minimax_anthropic_endpoint(base_url: str | None) -> bool: + """Return True for MiniMax's Anthropic-compatible endpoints. + + MiniMax rejects the fine-grained-tool-streaming and context-1m betas; + those need to be stripped even though MiniMax also uses Bearer auth. + """ + normalized = _normalize_base_url_text(base_url) + if not normalized: + return False + normalized = normalized.rstrip("/").lower() + return normalized.startswith( + ("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic") + ) + + def _common_betas_for_base_url( base_url: str | None, *, @@ -498,7 +517,9 @@ def _common_betas_for_base_url( MiniMax's Anthropic-compatible endpoints (Bearer-auth) reject requests that include Anthropic's ``fine-grained-tool-streaming`` beta — every - tool-use message triggers a connection error. + tool-use message triggers a connection error. They also reject the + 1M-context beta. Azure AI Foundry's Anthropic endpoint also uses + Bearer auth but keeps both betas (it needs the 1M beta for 1M context). The ``context-1m-2025-08-07`` beta is not sent to native Anthropic by default because some subscriptions reject it. Add it only for endpoint @@ -511,7 +532,7 @@ def _common_betas_for_base_url( betas = list(_COMMON_BETAS) if _base_url_needs_context_1m_beta(base_url) and not drop_context_1m_beta: betas.append(_CONTEXT_1M_BETA) - if _requires_bearer_auth(base_url): + if _is_minimax_anthropic_endpoint(base_url): _stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA} return [b for b in betas if b not in _stripped] if drop_context_1m_beta: diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index ba78833248..5d44fe1086 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -707,6 +707,17 @@ class _CodexCompletionsAdapter: # Tools support for auxiliary callers (e.g. skills_hub) that pass function schemas tools = kwargs.get("tools") if tools: + # xAI's Responses endpoint rejects ``pattern`` and ``format`` JSON Schema + # keywords (HTTP 400). Strip them here to match the parity guarantee that + # chat_completion_helpers.py provides for the main-agent xAI path. + try: + from tools.schema_sanitizer import strip_pattern_and_format + tools, _ = strip_pattern_and_format(list(tools)) + except Exception as exc: + logger.warning( + "Auxiliary client: failed to sanitize tool schemas for " + "Codex/xAI Responses path: %s", exc, + ) converted = [] for t in tools: fn = t.get("function", {}) if isinstance(t, dict) else {} diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 42e7c85743..018d84865c 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -58,13 +58,35 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu try: from tools.skills_tool import SKILLS_DIR, skill_view + from agent.skill_utils import get_external_skills_dirs identifier_path = Path(raw_identifier).expanduser() if identifier_path.is_absolute(): + normalized = None + trusted_roots = [SKILLS_DIR] try: - normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve())) + trusted_roots.extend(get_external_skills_dirs()) except Exception: - normalized = raw_identifier + pass + + # Prefer the lexical path under a trusted skill root before + # resolving symlinks. Slash-command discovery can legitimately + # find a skill via ~/.hermes/skills/ where is a + # symlink to a checked-out skill elsewhere. Resolving first turns + # that trusted visible path into an arbitrary absolute path that + # skill_view() refuses to load. + for root in trusted_roots: + try: + normalized = str(identifier_path.relative_to(root)) + break + except ValueError: + continue + + if normalized is None: + try: + normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve())) + except Exception: + normalized = raw_identifier else: normalized = raw_identifier.lstrip("/") diff --git a/apps/dashboard/src/components/AutoField.tsx b/apps/dashboard/src/components/AutoField.tsx index f7afd150b0..0f96d42042 100644 --- a/apps/dashboard/src/components/AutoField.tsx +++ b/apps/dashboard/src/components/AutoField.tsx @@ -17,6 +17,71 @@ function FieldHint({ schema, schemaKey }: { schema: Record; sch ); } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatScalar(value: unknown): string { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value); +} + +function NestedValueEditor({ + fieldKey, + value, + onChange, +}: { + fieldKey: string; + value: unknown; + onChange: (v: unknown) => void; +}) { + if (isRecord(value)) { + return ( +
+ {Object.entries(value).map(([subKey, subVal]) => ( +
+ + onChange({ ...value, [subKey]: next })} + /> +
+ ))} +
+ ); + } + + if (Array.isArray(value)) { + return ( +
+ {value.map((item, index) => ( +
+ + + onChange(value.map((existing, i) => (i === index ? next : existing))) + } + /> +
+ ))} +
+ ); + } + + return ( + onChange(e.target.value)} + className="text-xs" + /> + ); +} + export function AutoField({ schemaKey, schema, @@ -26,6 +91,16 @@ export function AutoField({ const rawLabel = schemaKey.split(".").pop() ?? schemaKey; const label = rawLabel.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + if (isRecord(value) || (Array.isArray(value) && value.some((item) => isRecord(item)))) { + return ( +
+ + + +
+ ); + } + if (schema.type === "boolean") { return (
@@ -114,26 +189,6 @@ export function AutoField({ ); } - if (typeof value === "object" && value !== null && !Array.isArray(value)) { - const obj = value as Record; - return ( -
- - - {Object.entries(obj).map(([subKey, subVal]) => ( -
- - onChange({ ...obj, [subKey]: e.target.value })} - className="text-xs" - /> -
- ))} -
- ); - } - return (
diff --git a/apps/dashboard/src/components/ThemeSwitcher.tsx b/apps/dashboard/src/components/ThemeSwitcher.tsx index 462ccaacfc..90a3d11ebd 100644 --- a/apps/dashboard/src/components/ThemeSwitcher.tsx +++ b/apps/dashboard/src/components/ThemeSwitcher.tsx @@ -79,7 +79,7 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) { role="listbox" aria-label={t.theme?.title ?? "Theme"} className={cn( - "absolute z-50 min-w-[240px]", + "absolute z-50 min-w-[240px] max-h-[70dvh] overflow-y-auto", dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1", "border border-current/20 bg-background-base/95 backdrop-blur-sm", "shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]", diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 09e870543a..9af045e226 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -61,6 +61,9 @@ fi # --- Running as hermes from here --- source "${INSTALL_DIR}/.venv/bin/activate" +# Stamp install method for detect_install_method() +echo "docker" > "${HERMES_HOME:=/opt/data}/.install_method" 2>/dev/null || true + # Create essential directory structure. Cache and platform directories # (cache/images, cache/audio, platforms/whatsapp, etc.) are created on # demand by the application — don't pre-create them here so new installs diff --git a/docs/plans/2026-05-15-acp-zed-edit-approval-diffs.md b/docs/plans/2026-05-15-acp-zed-edit-approval-diffs.md new file mode 100644 index 0000000000..4946291d4b --- /dev/null +++ b/docs/plans/2026-05-15-acp-zed-edit-approval-diffs.md @@ -0,0 +1,152 @@ +# ACP Zed Pre-Edit Approval Diffs Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Gate file mutations in ACP/Zed behind explicit pre-edit approval with a structured diff, similar to Codex/Kimi edit review behavior. + +**Architecture:** Hermes already renders edit diffs after tools run. This PR adds a pre-mutation permission gate for file mutation tools. Intercept `write_file`, `patch`, and eventually `skill_manage` before they mutate disk; compute proposed old/new content; send ACP `session/request_permission` with `kind="edit"` and diff content; only execute the mutation after approval. Rejections return a clear tool result and leave files unchanged. + +**Tech Stack:** Python, ACP `request_permission`, `FileEditToolCallContent` / `acp.tool_diff_content`, Hermes file tools, pytest with temp files. + +--- + +### Task 1: Confirm current ACP diff/permission schema + +Run: + +```bash +/home/nour/.hermes/hermes-agent/venv/bin/python - <<'PY' +from acp.schema import RequestPermissionRequest, ToolCallUpdate +import acp, inspect +print(RequestPermissionRequest.model_fields) +print(ToolCallUpdate.model_fields) +print(inspect.signature(acp.tool_diff_content)) +PY +``` + +Record actual field names. Do not rely on stale examples. + +### Task 2: Add denied-write test + +**Objective:** A rejected `write_file` must not mutate disk. + +**Files:** +- Create/modify: `tests/acp/test_edit_approval.py` + +Test shape: + +```python +def test_write_file_rejected_by_acp_permission_does_not_mutate(tmp_path): + path = tmp_path / "demo.txt" + path.write_text("old") + + # Install fake ACP edit approval callback returning reject_once. + # Invoke the same interception function that the terminal/tool path will call. + + result = maybe_gate_file_edit( + tool_name="write_file", + args={"path": str(path), "content": "new"}, + approval_requester=fake_reject, + ) + + assert path.read_text() == "old" + assert "rejected" in result.lower() +``` + +The exact function name will be created in Task 4. + +### Task 3: Add approved-write test + +**Objective:** Approved writes proceed and include diff content in permission request. + +Assert: + +- fake requester received tool call `kind == "edit"` +- content includes diff block for `demo.txt` +- after approval, file content is changed + +### Task 4: Implement edit proposal computation + +**Files:** +- Create: `acp_adapter/edit_approval.py` + +Add pure helpers first: + +```python +@dataclass +class EditProposal: + path: str + old_text: str | None + new_text: str + title: str + + +def proposal_for_write_file(args: dict[str, Any]) -> EditProposal: + path = str(args["path"]) + old_text = Path(path).read_text(encoding="utf-8") if Path(path).exists() else None + new_text = str(args.get("content", "")) + return EditProposal(path=path, old_text=old_text, new_text=new_text, title=f"Edit {path}") +``` + +For `patch`, start with replace-mode only. V4A/multi-file patches can be a second task or second PR if too risky. + +### Task 5: Implement ACP permission requester + +**Files:** +- Modify: `acp_adapter/permissions.py` or new `acp_adapter/edit_approval.py` + +Build request with: + +```python +acp.tool_diff_content(path=proposal.path, old_text=proposal.old_text, new_text=proposal.new_text) +``` + +Options: + +- allow once +- reject once +- optionally allow always/reject always only after policy storage exists + +Default deny on exception/cancel/timeout. + +### Task 6: Intercept file mutation tools before execution + +**Objective:** Ensure mutation cannot happen before approval. + +**Files:** +- Likely modify: `model_tools.py` or `acp_adapter/server.py` session-context tool wrapper + +Do not bury this inside post-execution `acp_adapter/events.py`; that is too late. + +Preferred design: + +- set an ACP session contextvar around `agent.run_conversation(...)` +- in the central tool execution path, before dispatching `write_file`/`patch`, call the ACP edit approval gate if contextvar exists +- if rejected, return a normal tool result string like `{"success": false, "error": "Edit rejected by user"}` +- if approved, continue to original tool implementation + +### Task 7: Expand patch coverage + +Add tests for: + +- `patch` replace mode approved/rejected +- creating a new file via `write_file` +- missing old string -> should fail before approval or return normal patch error, but must not mutate +- permission requester exception -> deny and no mutation + +### Task 8: Verification + +Run: + +```bash +scripts/run_tests.sh tests/acp/test_edit_approval.py tests/acp/test_events.py tests/acp/test_tools.py -q +``` + +Then run manual Zed verification: + +1. Ask Hermes ACP to edit a small file. +2. Confirm Zed shows a diff before mutation. +3. Reject and verify file unchanged. +4. Approve and verify file changed. + +**Do not merge** without manual reject-path verification. diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 96b56d29cc..34ebc385fa 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2157,12 +2157,20 @@ class BasePlatformAdapter(ABC): @staticmethod def extract_local_files(content: str) -> Tuple[List[str], str]: """ - Detect bare local file paths in response text for native media delivery. + Detect bare local file paths in response text for native delivery. Matches absolute paths (/...) and tilde paths (~/) ending in common - image or video extensions. Validates each candidate with - ``os.path.isfile()`` to avoid false positives from URLs or - non-existent paths. + image, video, audio, or document extensions. Validates each + candidate with ``os.path.isfile()`` to avoid false positives from + URLs or non-existent paths. + + The extension list is broader than just images/video so the agent + can produce arbitrary artifacts (charts, PDFs, spreadsheets, code + archives, CSVs) and have them ship to the user as native uploads + without needing an explicit ``MEDIA:`` tag. Image / video + extensions still embed inline where the platform supports it; + document extensions route through ``send_document``. The dispatch + partition lives in ``gateway/run.py``. Paths inside fenced code blocks (``` ... ```) and inline code (`...`) are ignored so that code samples are never mutilated. @@ -2172,8 +2180,22 @@ class BasePlatformAdapter(ABC): raw path strings removed). """ _LOCAL_MEDIA_EXTS = ( - '.png', '.jpg', '.jpeg', '.gif', '.webp', + # Images (embed inline) + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.svg', + # Video (embed inline where supported) '.mp4', '.mov', '.avi', '.mkv', '.webm', + # Audio (delivered as voice/audio where supported) + '.mp3', '.wav', '.ogg', '.m4a', '.flac', + # Documents (uploaded as file attachments) + '.pdf', '.docx', '.doc', '.odt', '.rtf', '.txt', '.md', + # Spreadsheets / data + '.xlsx', '.xls', '.ods', '.csv', '.tsv', '.json', '.xml', '.yaml', '.yml', + # Presentations + '.pptx', '.ppt', '.odp', '.key', + # Archives + '.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.rar', + # Web / rendered output + '.html', '.htm', ) ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS) diff --git a/gateway/run.py b/gateway/run.py index 25156932bf..5998f798e9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4474,6 +4474,29 @@ class GatewayRunner: "kanban notifier: delivered %s event for %s to %s/%s on board %s", kind, sub["task_id"], platform_str, sub["chat_id"], board_slug, ) + # After delivering the text notification, surface + # any artifact paths the worker referenced in + # ``kanban_complete(summary=..., artifacts=[...])`` + # (or the legacy ``result`` field) as native + # uploads. ``extract_local_files`` finds bare + # absolute paths in the summary; + # ``send_document`` / ``send_image_file`` uploads + # them. Only fires on the ``completed`` event so + # we never spam attachments on retries. + if kind == "completed": + try: + await self._deliver_kanban_artifacts( + adapter=adapter, + chat_id=sub["chat_id"], + metadata=metadata, + event_payload=getattr(ev, "payload", None), + task=task, + ) + except Exception as art_exc: + logger.debug( + "kanban notifier: artifact delivery for %s failed: %s", + sub["task_id"], art_exc, + ) # Reset the failure counter on success. sub_fail_counts.pop(sub_key, None) except Exception as exc: @@ -4591,6 +4614,110 @@ class GatewayRunner: finally: conn.close() + async def _deliver_kanban_artifacts( + self, + *, + adapter, + chat_id: str, + metadata: dict, + event_payload: Optional[dict], + task, + ) -> None: + """Upload artifact files referenced by a completed kanban task. + + Workers passing ``kanban_complete(artifacts=[...])`` ship absolute + file paths through the completion event so downstream humans get + the deliverable as a native upload instead of a path printed in + chat. + + Sources scanned, in priority order: + 1. ``event_payload['artifacts']`` (explicit list — preferred) + 2. ``event_payload['summary']`` (truncated first line) + 3. ``task.result`` (legacy fallback) + + Files are deduplicated, missing files are silently skipped (the + path may have been mentioned for reference only), and delivery + errors are logged but do not break the notifier loop. + """ + from pathlib import Path as _Path + + candidates: list[str] = [] + seen: set[str] = set() + + def _add(path: str) -> None: + if not path: + return + expanded = os.path.expanduser(path) + if expanded in seen: + return + if not os.path.isfile(expanded): + return + seen.add(expanded) + candidates.append(expanded) + + # 1. Explicit artifacts list in payload. + if isinstance(event_payload, dict): + raw = event_payload.get("artifacts") + if isinstance(raw, (list, tuple)): + for item in raw: + if isinstance(item, str): + _add(item) + + # 2. Paths embedded in the payload summary. + summary = event_payload.get("summary") + if isinstance(summary, str) and summary: + paths, _ = adapter.extract_local_files(summary) + for p in paths: + _add(p) + + # 3. Legacy: paths embedded in task.result. + if task is not None and getattr(task, "result", None): + result_text = str(task.result) + paths, _ = adapter.extract_local_files(result_text) + for p in paths: + _add(p) + + if not candidates: + return + + _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg"} + _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"} + + from urllib.parse import quote as _quote + + # Partition images so they ride a single send_multiple_images call + # on platforms that support batch image uploads (Signal/Slack RPCs). + image_paths = [p for p in candidates if _Path(p).suffix.lower() in _IMAGE_EXTS] + other_paths = [p for p in candidates if _Path(p).suffix.lower() not in _IMAGE_EXTS] + + if image_paths: + try: + batch = [(f"file://{_quote(p)}", "") for p in image_paths] + await adapter.send_multiple_images( + chat_id=chat_id, images=batch, metadata=metadata, + ) + except Exception as exc: + logger.warning( + "kanban notifier: image batch upload failed: %s", exc, + ) + + for path in other_paths: + ext = _Path(path).suffix.lower() + try: + if ext in _VIDEO_EXTS: + await adapter.send_video( + chat_id=chat_id, video_path=path, metadata=metadata, + ) + else: + await adapter.send_document( + chat_id=chat_id, file_path=path, metadata=metadata, + ) + except Exception as exc: + logger.warning( + "kanban notifier: artifact upload (%s) failed: %s", + path, exc, + ) + async def _kanban_dispatcher_watcher(self) -> None: """Embedded kanban dispatcher — one tick every `dispatch_interval_seconds`. @@ -8072,9 +8199,12 @@ class GatewayRunner: # message so the next message can load a transcript that # reflects what was said. Skip the assistant error text since # it's a gateway-generated hint, not model output. (#7100) + _user_entry = {"role": "user", "content": message_text, "timestamp": ts} + if event.message_id: + _user_entry["message_id"] = str(event.message_id) self.session_store.append_to_transcript( session_entry.session_id, - {"role": "user", "content": message_text, "timestamp": ts}, + _user_entry, ) else: history_len = agent_result.get("history_offset", len(history)) @@ -8082,9 +8212,12 @@ class GatewayRunner: # If no new messages found (edge case), fall back to simple user/assistant if not new_messages: + _user_entry = {"role": "user", "content": message_text, "timestamp": ts} + if event.message_id: + _user_entry["message_id"] = str(event.message_id) self.session_store.append_to_transcript( session_entry.session_id, - {"role": "user", "content": message_text, "timestamp": ts} + _user_entry, ) if response: self.session_store.append_to_transcript( @@ -8097,12 +8230,25 @@ class GatewayRunner: # to prevent the duplicate-write bug (#860). We still write # to JSONL for backward compatibility and as a backup. agent_persisted = self._session_db is not None + # Attach the inbound platform message_id to the first user + # entry written this turn so platform-level quote-resolution + # (e.g. Yuanbao QuoteContextMiddleware's transcript fallback) + # can find earlier @bot messages by their original message_id. + _user_msg_id_attached = False for msg in new_messages: # Skip system messages (they're rebuilt each run) if msg.get("role") == "system": continue # Add timestamp to each message for debugging entry = {**msg, "timestamp": ts} + if ( + not _user_msg_id_attached + and msg.get("role") == "user" + and event.message_id + and "message_id" not in entry + ): + entry["message_id"] = str(event.message_id) + _user_msg_id_attached = True self.session_store.append_to_transcript( session_entry.session_id, entry, skip_db=agent_persisted, diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 2e36920779..20e48986ad 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -189,21 +189,42 @@ def is_managed() -> bool: return get_managed_system() is not None +_NIX_UPDATE_MSG = "Update your Nix flake input and rebuild (e.g. nix flake update, nixos-rebuild, or home-manager switch)" + + def get_managed_update_command() -> Optional[str]: """Return the preferred upgrade command for a managed install.""" managed_system = get_managed_system() if managed_system == "Homebrew": return "brew upgrade hermes-agent" if managed_system == "NixOS": - return "sudo nixos-rebuild switch" + return _NIX_UPDATE_MSG return None def detect_install_method(project_root: Optional[Path] = None) -> str: - """Detect how Hermes was installed: 'nixos', 'homebrew', 'git', or 'pip'.""" + """Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'. + + Resolution order: + 1. Stamped ``~/.hermes/.install_method`` file (written by installers) + 2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew) + 3. Container detection (/.dockerenv, /run/.containerenv, cgroup) + 4. .git directory presence -> 'git' + 5. Fallback -> 'pip' + """ + stamp = get_hermes_home() / ".install_method" + try: + method = stamp.read_text(encoding="utf-8").strip().lower() + if method: + return method + except OSError: + pass managed = get_managed_system() if managed: return managed.lower().replace(" ", "-") + from hermes_constants import is_container + if is_container(): + return "docker" if project_root is None: project_root = Path(__file__).parent.parent.resolve() if (project_root / ".git").is_dir(): @@ -211,12 +232,24 @@ def detect_install_method(project_root: Optional[Path] = None) -> str: return "pip" +def stamp_install_method(method: str) -> None: + """Write the install method to ~/.hermes/.install_method.""" + stamp = get_hermes_home() / ".install_method" + try: + stamp.parent.mkdir(parents=True, exist_ok=True) + stamp.write_text(method + "\n", encoding="utf-8") + except OSError: + pass + + def recommended_update_command_for_method(method: str) -> str: - """Return the update command for a given install method.""" + """Return the update command or guidance for a given install method.""" if method == "nixos": - return "sudo nixos-rebuild switch" + return _NIX_UPDATE_MSG if method == "homebrew": return "brew upgrade hermes-agent" + if method == "docker": + return "docker pull nousresearch/hermes-agent:latest" if method == "pip": import shutil uv = shutil.which("uv") @@ -1493,6 +1526,11 @@ DEFAULT_CONFIG = { # same task/profile (spawn_failed, timed_out, or crashed). Reassignment # resets the streak for the new profile. "failure_limit": 2, + # Worker stdout/stderr logs rotate at spawn time. Defaults preserve + # the historical 2 MiB + one-backup behavior; long-running workers can + # raise these to keep more early failure evidence. + "worker_log_rotate_bytes": 2 * 1024 * 1024, + "worker_log_backup_count": 1, # Profile that decomposes tasks in the Triage column. When unset, # falls back to the default profile (the one `hermes` launches with # no -p flag). Set this to a dedicated 'orchestrator' profile if you diff --git a/hermes_cli/dep_ensure.py b/hermes_cli/dep_ensure.py index 1067b428f7..848e402396 100644 --- a/hermes_cli/dep_ensure.py +++ b/hermes_cli/dep_ensure.py @@ -16,11 +16,14 @@ browser tool needs agent-browser). from __future__ import annotations import os +import platform import shutil import subprocess import sys from pathlib import Path +_IS_WINDOWS = platform.system() == "Windows" + _DEP_CHECKS = { "node": lambda: shutil.which("node") is not None, "browser": lambda: ( @@ -41,7 +44,11 @@ _DEP_DESCRIPTIONS = { def _has_system_browser() -> bool: - for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"): + if _IS_WINDOWS: + names = ("chrome", "msedge", "chromium") + else: + names = ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome") + for name in names: if shutil.which(name): return True return False @@ -49,39 +56,67 @@ def _has_system_browser() -> bool: def _has_hermes_agent_browser() -> bool: from hermes_constants import get_hermes_home - return (get_hermes_home() / "node_modules" / ".bin" / "agent-browser").is_file() + home = get_hermes_home() + if _IS_WINDOWS: + # npm -g --prefix puts .cmd shims directly in the prefix dir on Windows + return (home / "node" / "agent-browser.cmd").is_file() + # install.sh installs globally into $HERMES_HOME/node/bin/ via npm -g --prefix + # Also check legacy node_modules/.bin/ path for git-clone installs. + return ( + (home / "node" / "bin" / "agent-browser").is_file() + or (home / "node_modules" / ".bin" / "agent-browser").is_file() + ) def _find_install_script( package_dir: Path | None = None, repo_root: Path | None = None, -) -> Path | None: - """Locate install.sh — bundled in wheel or in git checkout.""" +) -> tuple[Path | None, str | None]: + """Locate the install script — bundled in wheel or in git checkout. + + On Windows, prefers install.ps1; on POSIX, prefers install.sh. + Returns a (path, shell) tuple, or (None, None) if neither is found. + """ if package_dir is None: package_dir = Path(__file__).parent if repo_root is None: repo_root = package_dir.parent - bundled = package_dir / "scripts" / "install.sh" - if bundled.is_file(): - return bundled - repo = repo_root / "scripts" / "install.sh" - if repo.is_file(): - return repo - return None + if _IS_WINDOWS: + preferred = ("install.ps1", "powershell") + fallback = ("install.sh", "bash") + else: + preferred = ("install.sh", "bash") + fallback = ("install.ps1", "powershell") + + for script_name, shell in (preferred, fallback): + bundled = package_dir / "scripts" / script_name + if bundled.is_file(): + return bundled, shell + repo = repo_root / "scripts" / script_name + if repo.is_file(): + return repo, shell + + return None, None -def ensure_dependency(dep: str, interactive: bool = True) -> bool: +def ensure_dependency( + dep: str, + interactive: bool = True, +) -> bool: """Ensure a non-Python dependency is available. Returns True if available.""" check = _DEP_CHECKS.get(dep) - if check and check(): + if check is None: + # Unknown dep — don't silently forward to install script. + return False + if check(): return True - script = _find_install_script() + script, shell = _find_install_script() if script is None: if interactive: desc = _DEP_DESCRIPTIONS.get(dep, dep) - print(f" {desc} is not installed and install.sh was not found.") + print(f" {desc} is not installed and no install script was found.") print(f" Install {dep} manually and try again.") return False @@ -91,12 +126,30 @@ def ensure_dependency(dep: str, interactive: bool = True) -> bool: reply = input(f"{desc} is not installed. Install now? [Y/n] ").strip().lower() except (EOFError, KeyboardInterrupt): return False - if reply not in {"", "y", "yes"}: + if reply not in ("", "y", "yes"): return False + if shell == "powershell": + from hermes_constants import get_hermes_home + ps_bin = shutil.which("powershell") or shutil.which("pwsh") + if not ps_bin: + if interactive: + print(" PowerShell not found. Install PowerShell or run install.ps1 manually.") + return False + cmd = [ + ps_bin, + "-ExecutionPolicy", "Bypass", + "-File", str(script), + "-Ensure", dep, + "-HermesHome", str(get_hermes_home()), + ] + else: + cmd = ["bash", str(script), "--ensure", dep] + + run_env = {**os.environ, "IS_INTERACTIVE": "false"} result = subprocess.run( - ["bash", str(script), "--ensure", dep], - env={**os.environ, "IS_INTERACTIVE": "false"}, + cmd, + env=run_env, ) if result.returncode != 0: return False diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 87043bc261..4440b38682 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -195,6 +195,18 @@ def check_info(text: str): print(f" {color('→', Colors.CYAN)} {text}") +def _section(title: str) -> None: + """Print a doctor section banner: blank line + bold cyan ◆ title.""" + print() + print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD)) + + +def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None: + """Emit a check_fail and append the corresponding fix instruction.""" + check_fail(text, detail) + issues.append(fix) + + def _check_gateway_service_linger(issues: list[str]) -> None: """Warn when a systemd user gateway service will stop after logout.""" try: @@ -214,9 +226,7 @@ def _check_gateway_service_linger(issues: list[str]) -> None: if not unit_path.exists(): return - print() - print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD)) - + _section("Gateway Service") linger_enabled, linger_detail = get_systemd_linger_status() if linger_enabled is True: check_ok("Systemd linger enabled", "(gateway service survives logout)") @@ -373,11 +383,7 @@ def run_doctor(args): print(color("│ 🩺 Hermes Doctor │", Colors.CYAN)) print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN)) - # ========================================================================= - # Check: Security advisories (RUNS FIRST — these are the most urgent) - # ========================================================================= - print() - print(color("◆ Security Advisories", Colors.CYAN, Colors.BOLD)) + _section("Security Advisories") try: from hermes_cli.security_advisories import ( detect_compromised, @@ -423,12 +429,7 @@ def run_doctor(args): # Never let a bug in the advisory check block the rest of doctor. check_warn(f"Security advisory check failed: {e}") - # ========================================================================= - # Check: Python version - # ========================================================================= - print() - print(color("◆ Python Environment", Colors.CYAN, Colors.BOLD)) - + _section("Python Environment") py_version = sys.version_info if py_version >= (3, 11): check_ok(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}") @@ -438,8 +439,12 @@ def run_doctor(args): elif py_version >= (3, 8): check_warn(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", "(3.10+ recommended)") else: - check_fail(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", "(3.10+ required)") - issues.append("Upgrade Python to 3.10+") + _fail_and_issue( + f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", + "(3.10+ required)", + "Upgrade Python to 3.10+", + issues, + ) # Check if in virtual environment in_venv = sys.prefix != sys.base_prefix @@ -448,12 +453,7 @@ def run_doctor(args): else: check_warn("Not in virtual environment", "(recommended)") - # ========================================================================= - # Check: Required packages - # ========================================================================= - print() - print(color("◆ Required Packages", Colors.CYAN, Colors.BOLD)) - + _section("Required Packages") required_packages = [ ("openai", "OpenAI SDK"), ("rich", "Rich (terminal UI)"), @@ -473,8 +473,7 @@ def run_doctor(args): __import__(module) check_ok(name) except ImportError: - check_fail(name, "(missing)") - issues.append(f"Install {name}: {_python_install_cmd()} {module}") + _fail_and_issue(name, "(missing)", f"Install {name}: {_python_install_cmd()} {module}", issues) for module, name in optional_packages: try: @@ -483,12 +482,7 @@ def run_doctor(args): except ImportError: check_warn(name, "(optional, not installed)") - # ========================================================================= - # Check: Configuration files - # ========================================================================= - print() - print(color("◆ Configuration Files", Colors.CYAN, Colors.BOLD)) - + _section("Configuration Files") # Check ~/.hermes/.env (primary location for user config) env_path = HERMES_HOME / '.env' if env_path.exists(): @@ -611,14 +605,15 @@ def run_doctor(args): and not (provider_ids_to_accept & valid_provider_ids) ): known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)" - check_fail( + _fail_and_issue( f"model.provider '{provider_raw}' is not a recognised provider", f"(known: {known_list})", - ) - issues.append( - f"model.provider '{provider_raw}' is unknown. " - f"Valid providers: {known_list}. " - f"Fix: run 'hermes config set model.provider '" + ( + f"model.provider '{provider_raw}' is unknown. " + f"Valid providers: {known_list}. " + f"Fix: run 'hermes config set model.provider '" + ), + issues, ) # Warn if model is set to a provider-prefixed name on a provider that doesn't use them @@ -677,14 +672,15 @@ def run_doctor(args): or status.get("api_key") ) if not configured: - check_fail( + _fail_and_issue( f"model.provider '{runtime_provider}' is set but no API key is configured", "(check ~/.hermes/.env or run 'hermes setup')", - ) - issues.append( - f"No credentials found for provider '{runtime_provider}'. " - f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, " - f"or switch providers with 'hermes config set model.provider '" + ( + f"No credentials found for provider '{runtime_provider}'. " + f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, " + f"or switch providers with 'hermes config set model.provider '" + ), + issues, ) except Exception: pass @@ -768,8 +764,7 @@ def run_doctor(args): from hermes_cli.config import validate_config_structure config_issues = validate_config_structure() if config_issues: - print() - print(color("◆ Config Structure", Colors.CYAN, Colors.BOLD)) + _section("Config Structure") for ci in config_issues: if ci.severity == "error": check_fail(ci.message) @@ -782,12 +777,7 @@ def run_doctor(args): except Exception: pass - # ========================================================================= - # Check: Auth providers - # ========================================================================= - print() - print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD)) - + _section("Auth Providers") try: from hermes_cli.auth import ( get_nous_auth_status, @@ -859,12 +849,7 @@ def run_doctor(args): "(optional — only required to import tokens from an existing Codex CLI login)" ) - # ========================================================================= - # Check: Directory structure - # ========================================================================= - print() - print(color("◆ Directory Structure", Colors.CYAN, Colors.BOLD)) - + _section("Directory Structure") hermes_home = HERMES_HOME if hermes_home.exists(): check_ok(f"{_DHH} directory exists") @@ -976,13 +961,8 @@ def run_doctor(args): _check_gateway_service_linger(issues) - # ========================================================================= - # Check: Command installation (hermes bin symlink) - # ========================================================================= if sys.platform != "win32": - print() - print(color("◆ Command Installation", Colors.CYAN, Colors.BOLD)) - + _section("Command Installation") # Determine the venv entry point location _venv_bin = None for _venv_name in ("venv", ".venv"): @@ -1056,12 +1036,7 @@ def run_doctor(args): else: issues.append(f"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'") - # ========================================================================= - # Check: External tools - # ========================================================================= - print() - print(color("◆ External Tools", Colors.CYAN, Colors.BOLD)) - + _section("External Tools") # Git if _safe_which("git"): check_ok("git") @@ -1087,11 +1062,14 @@ def run_doctor(args): if result is not None and result.returncode == 0: check_ok("docker", "(daemon running)") else: - check_fail("docker daemon not running") - issues.append("Start Docker daemon") + _fail_and_issue("docker daemon not running", "", "Start Docker daemon", issues) else: - check_fail("docker not found", "(required for TERMINAL_ENV=docker)") - issues.append("Install Docker or change TERMINAL_ENV") + _fail_and_issue( + "docker not found", + "(required for TERMINAL_ENV=docker)", + "Install Docker or change TERMINAL_ENV", + issues, + ) elif _safe_which("docker"): check_ok("docker", "(optional)") elif _is_termux(): @@ -1126,11 +1104,14 @@ def run_doctor(args): if result is not None and result.returncode == 0: check_ok(f"SSH connection to {ssh_host}") else: - check_fail(f"SSH connection to {ssh_host}") - issues.append(f"Check SSH configuration for {ssh_host}") + _fail_and_issue(f"SSH connection to {ssh_host}", "", f"Check SSH configuration for {ssh_host}", issues) else: - check_fail("TERMINAL_SSH_HOST not set", "(required for TERMINAL_ENV=ssh)") - issues.append("Set TERMINAL_SSH_HOST in .env") + _fail_and_issue( + "TERMINAL_SSH_HOST not set", + "(required for TERMINAL_ENV=ssh)", + "Set TERMINAL_SSH_HOST in .env", + issues, + ) # Daytona (if using daytona backend) if terminal_env == "daytona": @@ -1138,14 +1119,22 @@ def run_doctor(args): if daytona_key: check_ok("Daytona API key", "(configured)") else: - check_fail("DAYTONA_API_KEY not set", "(required for TERMINAL_ENV=daytona)") - issues.append("Set DAYTONA_API_KEY environment variable") + _fail_and_issue( + "DAYTONA_API_KEY not set", + "(required for TERMINAL_ENV=daytona)", + "Set DAYTONA_API_KEY environment variable", + issues, + ) try: from daytona import Daytona # noqa: F401 — SDK presence check check_ok("daytona SDK", "(installed)") except ImportError: - check_fail("daytona SDK not installed", "(pip install daytona)") - issues.append("Install daytona SDK: pip install daytona") + _fail_and_issue( + "daytona SDK not installed", + "(pip install daytona)", + "Install daytona SDK: pip install daytona", + issues, + ) # Vercel Sandbox (if using vercel_sandbox backend) if terminal_env == "vercel_sandbox": @@ -1155,32 +1144,50 @@ def run_doctor(args): check_ok("Vercel runtime", f"({runtime})") else: supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES) - check_fail("Vercel runtime unsupported", f"({runtime}; use {supported})") - issues.append(f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}") + _fail_and_issue( + "Vercel runtime unsupported", + f"({runtime}; use {supported})", + f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}", + issues, + ) disk = os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip() if disk in {"", "0", "51200"}: check_ok("Vercel disk setting", "(uses platform default)") else: - check_fail("Vercel custom disk unsupported", "(reset terminal.container_disk to 51200)") - issues.append("Vercel Sandbox does not support custom container_disk; use the shared default 51200") + _fail_and_issue( + "Vercel custom disk unsupported", + "(reset terminal.container_disk to 51200)", + "Vercel Sandbox does not support custom container_disk; use the shared default 51200", + issues, + ) if importlib.util.find_spec("vercel") is not None: check_ok("vercel SDK", "(installed)") else: - check_fail("vercel SDK not installed", "(pip install 'hermes-agent[vercel]')") - issues.append("Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'") + _fail_and_issue( + "vercel SDK not installed", + "(pip install 'hermes-agent[vercel]')", + "Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'", + issues, + ) auth_status = describe_vercel_auth() if auth_status.ok: check_ok("Vercel auth", f"({auth_status.label})") elif auth_status.label.startswith("partial"): - check_fail("Vercel auth incomplete", f"({auth_status.label})") - issues.append("Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together") + _fail_and_issue( + "Vercel auth incomplete", + f"({auth_status.label})", + "Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together", + issues, + ) else: - check_fail("Vercel auth not configured", f"({auth_status.label})") - issues.append( - "Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID" + _fail_and_issue( + "Vercel auth not configured", + f"({auth_status.label})", + "Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID", + issues, ) for line in auth_status.detail_lines: check_info(f"Vercel auth {line}") @@ -1320,12 +1327,7 @@ def run_doctor(args): for note in _termux_install_all_fallback_notes(): check_info(note) - # ========================================================================= - # Check: API connectivity - # ========================================================================= - print() - print(color("◆ API Connectivity", Colors.CYAN, Colors.BOLD)) - + _section("API Connectivity") # Refactor: every connectivity probe below is HTTP-bound and fully # independent. Running them in series spent ~5s wall on a typical # workstation (2s of that was boto3's IMDS lookup for AWS credentials, @@ -1673,12 +1675,7 @@ def run_doctor(args): for _issue in _issues_to_add: issues.append(_issue) - # ========================================================================= - # Check: Tool Availability - # ========================================================================= - print() - print(color("◆ Tool Availability", Colors.CYAN, Colors.BOLD)) - + _section("Tool Availability") try: # Add project root to path for imports sys.path.insert(0, str(PROJECT_ROOT)) @@ -1706,12 +1703,7 @@ def run_doctor(args): except Exception as e: check_warn("Could not check tool availability", f"({e})") - # ========================================================================= - # Check: Skills Hub - # ========================================================================= - print() - print(color("◆ Skills Hub", Colors.CYAN, Colors.BOLD)) - + _section("Skills Hub") hub_dir = HERMES_HOME / "skills" / ".hub" if hub_dir.exists(): check_ok("Skills Hub directory exists") @@ -1752,12 +1744,7 @@ def run_doctor(args): else: check_warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)") - # ========================================================================= - # Memory Provider (only check the active provider, if any) - # ========================================================================= - print() - print(color("◆ Memory Provider", Colors.CYAN, Colors.BOLD)) - + _section("Memory Provider") _active_memory_provider = "" try: import yaml as _yaml @@ -1782,8 +1769,12 @@ def run_doctor(args): elif not hcfg.enabled: check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)") elif not (hcfg.api_key or hcfg.base_url): - check_fail("Honcho API key or base URL not set", "run: hermes memory setup") - issues.append("No Honcho API key — run 'hermes memory setup'") + _fail_and_issue( + "Honcho API key or base URL not set", + "run: hermes memory setup", + "No Honcho API key — run 'hermes memory setup'", + issues, + ) else: from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client reset_honcho_client() @@ -1794,11 +1785,14 @@ def run_doctor(args): f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}", ) except Exception as _e: - check_fail("Honcho connection failed", str(_e)) - issues.append(f"Honcho unreachable: {_e}") + _fail_and_issue("Honcho connection failed", str(_e), f"Honcho unreachable: {_e}", issues) except ImportError: - check_fail("honcho-ai not installed", "pip install honcho-ai") - issues.append("Honcho is set as memory provider but honcho-ai is not installed") + _fail_and_issue( + "honcho-ai not installed", + "pip install honcho-ai", + "Honcho is set as memory provider but honcho-ai is not installed", + issues, + ) except Exception as _e: check_warn("Honcho check failed", str(_e)) elif _active_memory_provider == "mem0": @@ -1810,11 +1804,19 @@ def run_doctor(args): check_ok("Mem0 API key configured") check_info(f"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}") else: - check_fail("Mem0 API key not set", "(set MEM0_API_KEY in .env or run hermes memory setup)") - issues.append("Mem0 is set as memory provider but API key is missing") + _fail_and_issue( + "Mem0 API key not set", + "(set MEM0_API_KEY in .env or run hermes memory setup)", + "Mem0 is set as memory provider but API key is missing", + issues, + ) except ImportError: - check_fail("Mem0 plugin not loadable", "pip install mem0ai") - issues.append("Mem0 is set as memory provider but mem0ai is not installed") + _fail_and_issue( + "Mem0 plugin not loadable", + "pip install mem0ai", + "Mem0 is set as memory provider but mem0ai is not installed", + issues, + ) except Exception as _e: check_warn("Mem0 check failed", str(_e)) else: @@ -1831,17 +1833,13 @@ def run_doctor(args): except Exception as _e: check_warn(f"{_active_memory_provider} check failed", str(_e)) - # ========================================================================= - # Profiles - # ========================================================================= try: from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists import re as _re named_profiles = [p for p in list_profiles() if not p.is_default] if named_profiles: - print() - print(color("◆ Profiles", Colors.CYAN, Colors.BOLD)) + _section("Profiles") check_ok(f"{len(named_profiles)} profile(s) found") wrapper_dir = _get_wrapper_dir() for p in named_profiles: @@ -1878,9 +1876,6 @@ def run_doctor(args): except Exception: pass - # ========================================================================= - # Summary - # ========================================================================= print() remaining_issues = issues + manual_issues if should_fix and fixed_count > 0: diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 55b1d4125a..edaee42f88 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -1393,6 +1393,9 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int: the dashboard uses, so CLI output matches what the UI shows. """ from hermes_cli import kanban_diagnostics as kd + from hermes_cli.config import load_config + + diag_config = kd.config_from_runtime_config(load_config()) with kb.connect() as conn: # Either one-task mode or fleet mode. @@ -1406,6 +1409,7 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int: task, kb.list_events(conn, args.task), kb.list_runs(conn, args.task), + config=diag_config, ) } else: @@ -1433,7 +1437,12 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int: diags_by_task = {} for r in rows: tid = r["id"] - dl = kd.compute_task_diagnostics(r, ev_by.get(tid, []), run_by.get(tid, [])) + dl = kd.compute_task_diagnostics( + r, + ev_by.get(tid, []), + run_by.get(tid, []), + config=diag_config, + ) if dl: diags_by_task[tid] = dl diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 4bd4827e38..5c2d032408 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -2479,6 +2479,20 @@ def complete_task( } if verified_cards: completed_payload["verified_cards"] = verified_cards + # Carry artifact paths in the event payload so the gateway + # notifier can upload them as native attachments alongside the + # completion message. Workers pass these via + # ``kanban_complete(artifacts=[...])`` which stashes the list in + # ``metadata["artifacts"]`` — we promote it onto the event so + # consumers don't have to fetch the run row to find it. + if isinstance(metadata, dict): + md_artifacts = metadata.get("artifacts") + if isinstance(md_artifacts, (list, tuple)): + cleaned_artifacts = [ + str(p).strip() for p in md_artifacts if isinstance(p, str) and str(p).strip() + ] + if cleaned_artifacts: + completed_payload["artifacts"] = cleaned_artifacts _append_event( conn, task_id, "completed", completed_payload, @@ -2835,6 +2849,29 @@ def decompose_triage_task( if p == idx: raise ValueError(f"child[{idx}] cannot list itself as a parent") + # Detect cycles in the sibling parent graph (Kahn's topological sort). + # link_tasks() calls _would_cycle() for every new edge; here we check + # the entire sibling graph before touching the DB. A cycle silently + # deadlocks every involved child in 'todo' because recompute_ready() + # can never promote them. + _in_deg = [0] * len(children) + _adj: list[list[int]] = [[] for _ in range(len(children))] + for _i, _c in enumerate(children): + for _p in (_c.get("parents") or []): + _adj[_p].append(_i) + _in_deg[_i] += 1 + _queue = [_i for _i in range(len(children)) if _in_deg[_i] == 0] + _seen = 0 + while _queue: + _node = _queue.pop() + _seen += 1 + for _nb in _adj[_node]: + _in_deg[_nb] -= 1 + if _in_deg[_nb] == 0: + _queue.append(_nb) + if _seen != len(children): + raise ValueError("cyclic dependency detected in decomposed children list") + # We do the full decomposition in a SINGLE write_txn so it's # atomic: either every child is created AND the root flips to # ``todo``, or nothing changes. We deliberately do NOT call any @@ -3066,6 +3103,11 @@ DEFAULT_SPAWN_FAILURE_LIMIT = DEFAULT_FAILURE_LIMIT # Max bytes to keep in a single worker log file. The dispatcher truncates # and rotates on spawn if the file is larger than this at spawn time. DEFAULT_LOG_ROTATE_BYTES = 2 * 1024 * 1024 # 2 MiB +DEFAULT_LOG_BACKUP_COUNT = 1 + +# Keep a little wall-clock budget for the worker to observe a terminal timeout +# and call kanban_block/kanban_complete before max_runtime_seconds kills it. +KANBAN_TERMINAL_TIMEOUT_GRACE_SECONDS = 30 @dataclass @@ -4025,25 +4067,84 @@ def dispatch_once( return result -def _rotate_worker_log(log_path: Path, max_bytes: int) -> None: - """Rotate ```` to ``.1`` if it exceeds ``max_bytes``. +def _positive_int(value: Any, default: int, *, minimum: int = 1) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= minimum else default - Single-generation rotation — one old file kept, newer one replaces it. - Keeps disk usage bounded while still giving the user a chance to grab - the prior run's output. + +def worker_log_rotation_config(kanban_cfg: Optional[dict] = None) -> tuple[int, int]: + """Return ``(rotate_bytes, backup_count)`` for worker log rotation. + + Defaults preserve the historical behavior: rotate at 2 MiB and keep one + backup generation (``.log.1``). Operators with long-running workers can + raise either value from ``config.yaml`` without changing dispatcher code. + """ + if kanban_cfg is None: + try: + from hermes_cli.config import load_config + + kanban_cfg = (load_config().get("kanban") or {}) + except Exception: + kanban_cfg = {} + max_bytes = _positive_int( + (kanban_cfg or {}).get("worker_log_rotate_bytes"), + DEFAULT_LOG_ROTATE_BYTES, + minimum=1, + ) + backup_count = _positive_int( + (kanban_cfg or {}).get("worker_log_backup_count"), + DEFAULT_LOG_BACKUP_COUNT, + minimum=0, + ) + return max_bytes, backup_count + + +def _rotated_log_path(log_path: Path, generation: int) -> Path: + return log_path.with_suffix(log_path.suffix + f".{generation}") + + +def _rotate_worker_log( + log_path: Path, + max_bytes: int, + backup_count: int = DEFAULT_LOG_BACKUP_COUNT, +) -> None: + """Rotate ```` when it exceeds ``max_bytes``. + + ``backup_count=1`` preserves the legacy single-generation behavior: + ```` moves to ``.1`` and any previous ``.1`` is replaced. + Higher values shift older generations up to ``backup_count``. """ try: if not log_path.exists(): return if log_path.stat().st_size <= max_bytes: return - rotated = log_path.with_suffix(log_path.suffix + ".1") + backup_count = _positive_int( + backup_count, + DEFAULT_LOG_BACKUP_COUNT, + minimum=0, + ) + if backup_count == 0: + log_path.unlink() + return + oldest = _rotated_log_path(log_path, backup_count) try: - if rotated.exists(): - rotated.unlink() + if oldest.exists(): + oldest.unlink() except OSError: pass - log_path.rename(rotated) + for generation in range(backup_count - 1, 0, -1): + src = _rotated_log_path(log_path, generation) + if not src.exists(): + continue + try: + src.rename(_rotated_log_path(log_path, generation + 1)) + except OSError: + pass + log_path.rename(_rotated_log_path(log_path, 1)) except OSError: pass @@ -4077,6 +4178,36 @@ def _resolve_hermes_argv() -> list[str]: return [sys.executable, "-m", "hermes_cli.main"] +def _worker_terminal_timeout_env( + max_runtime_seconds: Optional[int], + current_timeout: Optional[str], +) -> Optional[str]: + """Return a worker-scoped TERMINAL_TIMEOUT override, if needed. + + Kanban's ``max_runtime_seconds`` bounds the whole worker attempt. The + terminal tool has its own default timeout via ``TERMINAL_TIMEOUT``; when + the worker runtime is longer, raise only the child process default so a + long command is not killed by the generic terminal default first. + """ + if max_runtime_seconds is None: + return None + try: + runtime = int(max_runtime_seconds) + except (TypeError, ValueError): + return None + if runtime <= 0: + return None + + desired = max(1, runtime - KANBAN_TERMINAL_TIMEOUT_GRACE_SECONDS) + try: + existing = int(str(current_timeout).strip()) if current_timeout else 0 + except (TypeError, ValueError): + existing = 0 + if existing >= desired: + return None + return str(desired) + + def _default_spawn( task: Task, workspace: str, @@ -4132,6 +4263,18 @@ def _default_spawn( env["HERMES_KANBAN_RUN_ID"] = str(task.current_run_id) if task.claim_lock: env["HERMES_KANBAN_CLAIM_LOCK"] = task.claim_lock + terminal_timeout = _worker_terminal_timeout_env( + task.max_runtime_seconds, + env.get("TERMINAL_TIMEOUT"), + ) + if terminal_timeout is not None: + env["TERMINAL_TIMEOUT"] = terminal_timeout + foreground_timeout = _worker_terminal_timeout_env( + task.max_runtime_seconds, + env.get("TERMINAL_MAX_FOREGROUND_TIMEOUT"), + ) + if foreground_timeout is not None: + env["TERMINAL_MAX_FOREGROUND_TIMEOUT"] = foreground_timeout # Pin the shared board + workspaces root the dispatcher resolved, so # that even when the worker activates a profile (`hermes -p ` # rewrites HERMES_HOME), its kanban paths still match the @@ -4186,7 +4329,8 @@ def _default_spawn( log_dir = worker_logs_dir(board=board) log_dir.mkdir(parents=True, exist_ok=True) log_path = log_dir / f"{task.id}.log" - _rotate_worker_log(log_path, DEFAULT_LOG_ROTATE_BYTES) + rotate_bytes, backup_count = worker_log_rotation_config() + _rotate_worker_log(log_path, rotate_bytes, backup_count) # Use 'a' so a re-run on unblock appends rather than overwrites. log_f = open(log_path, "ab") @@ -4322,6 +4466,15 @@ def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str: if task.tenant: lines.append(f"Tenant: {task.tenant}") lines.append(f"Workspace: {task.workspace_kind} @ {task.workspace_path or '(unresolved)'}") + if task.max_runtime_seconds is not None: + terminal_timeout = _worker_terminal_timeout_env( + task.max_runtime_seconds, + os.environ.get("TERMINAL_TIMEOUT"), + ) + effective_terminal_timeout = terminal_timeout or os.environ.get("TERMINAL_TIMEOUT") + lines.append(f"Max runtime: {task.max_runtime_seconds}s") + if effective_terminal_timeout: + lines.append(f"Terminal timeout: {effective_terminal_timeout}s") lines.append("") if task.body and task.body.strip(): diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index 42c0c2043f..8acd6dd932 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -230,6 +230,106 @@ def _generic_recovery_actions(task: Any, *, running: bool) -> list[DiagnosticAct RuleFn = Callable[[Any, list[Any], list[Any], int, dict], list[Diagnostic]] +def _aux_slot_explicit(slot: Any) -> bool: + """Return True if the auxiliary slot has user-supplied non-default fields. + + Defaults from ``DEFAULT_CONFIG`` use ``provider: "auto"`` with empty + model/base_url/api_key — that path falls through to the main model. An + "explicit" config is one where the user actively set a provider (not + "auto"), or supplied a model / base_url / api_key. + """ + if not isinstance(slot, dict): + return False + provider = str(slot.get("provider") or "").strip().lower() + if provider and provider != "auto": + return True + for key in ("model", "base_url", "api_key"): + if str(slot.get(key) or "").strip(): + return True + return False + + +def _main_model_visible(raw_config: Any) -> bool: + """Best-effort check that a main model is configured. + + Diagnostics runs in the dashboard process which may not share the CLI's + runtime state, so we read the raw config dict. If we cannot prove the + main model is set, we err on the side of NOT firing the diagnostic. + """ + if not isinstance(raw_config, dict): + return False + model_cfg = raw_config.get("model") + if isinstance(model_cfg, dict): + provider = str(model_cfg.get("provider") or "").strip() + model = str( + model_cfg.get("default") + or model_cfg.get("model") + or model_cfg.get("name") + or "" + ).strip() + return bool(provider and model) + return bool(str(model_cfg or "").strip()) + + +def triage_aux_status(config: Optional[dict]) -> Optional[dict]: + """Inspect raw config and report whether triage paths look configured. + + Returns ``None`` when config context is unavailable (suppress diagnostic + to avoid noisy false positives in tests / low-level callers). Otherwise + returns a dict with: + + - ``auto_decompose``: bool — whether the dispatcher auto-runs decompose + - ``decomposer_explicit``: bool — user-supplied decomposer slot + - ``specifier_explicit``: bool — user-supplied specifier slot + - ``main_model_visible``: bool — main model can serve as auto fallback + """ + if not isinstance(config, dict): + return None + + explicit = config.get("triage_aux_status") + if isinstance(explicit, dict): + return explicit + + aux = config.get("auxiliary") + kanban_cfg = config.get("kanban") if isinstance(config.get("kanban"), dict) else {} + + # Have we been handed any config context at all? When neither auxiliary + # nor kanban nor model keys are present, the caller is a low-level test + # passing {} — stay silent. + if ( + not isinstance(aux, dict) + and not kanban_cfg + and "model" not in config + ): + return None + + decomposer_explicit = False + specifier_explicit = False + if isinstance(aux, dict): + decomposer_explicit = _aux_slot_explicit(aux.get("kanban_decomposer")) + specifier_explicit = _aux_slot_explicit(aux.get("triage_specifier")) + + # ``auto_decompose`` defaults to True per kanban DEFAULT_CONFIG. + auto_decompose = True + if isinstance(kanban_cfg, dict) and "auto_decompose" in kanban_cfg: + auto_decompose = bool(kanban_cfg.get("auto_decompose")) + + return { + "auto_decompose": auto_decompose, + "decomposer_explicit": decomposer_explicit, + "specifier_explicit": specifier_explicit, + "main_model_visible": _main_model_visible(config), + } + + +def _positive_int(value: Any, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= 1 else default + + def _rule_hallucinated_cards(task, events, runs, now, cfg) -> list[Diagnostic]: """Blocked-hallucination gate fires: a worker called kanban_complete with created_cards that didn't exist or weren't created by the @@ -277,6 +377,118 @@ def _rule_hallucinated_cards(task, events, runs, now, cfg) -> list[Diagnostic]: )] +def _rule_triage_aux_unavailable(task, events, runs, now, cfg) -> list[Diagnostic]: + """A triage task cannot leave triage without an auxiliary helper. + + With the auto-decompose dispatcher (kanban.auto_decompose, default True), + triage tasks fan out via ``auxiliary.kanban_decomposer`` and fall back to + ``auxiliary.triage_specifier`` when the decomposer returns ``fanout=false``. + With auto-decompose off, the user must run ``hermes kanban specify``, + which only needs ``auxiliary.triage_specifier``. + + The default slot is ``provider: auto`` → auto-falls back to the main model, + so this rule only fires when: + + - the relevant slot is explicitly set to something broken, OR + - the auto fallback has no main model to fall back to. + + Config context is required; pass {} from tests to keep the rule silent. + """ + if _task_field(task, "status") != "triage": + return [] + + status = triage_aux_status(cfg) + if status is None: + return [] + + auto_decompose = bool(status.get("auto_decompose")) + decomposer_explicit = bool(status.get("decomposer_explicit")) + specifier_explicit = bool(status.get("specifier_explicit")) + main_visible = bool(status.get("main_model_visible")) + + # Determine the primary slot and whether it is usable. + if auto_decompose: + primary_slot = "auxiliary.kanban_decomposer" + primary_explicit = decomposer_explicit + fallback_slot = "auxiliary.triage_specifier" + fallback_explicit = specifier_explicit + primary_desc = "decomposer" + detail_path = ( + "Auto-decompose is on, so the dispatcher needs " + "auxiliary.kanban_decomposer (with auxiliary.triage_specifier as " + "a fallback for non-fan-out tasks)." + ) + else: + primary_slot = "auxiliary.triage_specifier" + primary_explicit = specifier_explicit + fallback_slot = "auxiliary.kanban_decomposer" + fallback_explicit = decomposer_explicit + primary_desc = "specifier" + detail_path = ( + "Auto-decompose is off, so triage tasks need " + "`hermes kanban specify`, which uses auxiliary.triage_specifier." + ) + + # The primary slot is usable when either: it was explicitly configured by + # the user, OR the default `provider: auto` can fall back to the main + # model. If both fail, we have a real configuration gap. + if primary_explicit or main_visible: + return [] + + task_id = _task_field(task, "id") or "" + actions = [ + DiagnosticAction( + kind="cli_hint", + label=f"Configure {primary_slot}", + payload={ + "command": ( + f"hermes config set {primary_slot}.provider auto" + ) + }, + suggested=True, + ), + ] + if not fallback_explicit and not main_visible: + actions.append(DiagnosticAction( + kind="cli_hint", + label=f"Or configure fallback {fallback_slot}", + payload={ + "command": ( + f"hermes config set {fallback_slot}.provider auto" + ) + }, + )) + if not auto_decompose: + actions.append(DiagnosticAction( + kind="cli_hint", + label=f"Specify manually: hermes kanban specify {task_id}", + payload={"command": f"hermes kanban specify {task_id}"}, + )) + + return [Diagnostic( + kind="triage_aux_unavailable", + severity="warning", + title=f"Triage {primary_desc} has no usable model", + detail=( + f"This task is still in triage and no working auxiliary model is " + f"visible to the dispatcher. {detail_path} The default slot uses " + f"`provider: auto` which falls back to the main model, but no main " + f"model is configured either. Configure the slot directly or set a " + f"main model so the auto fallback can take over." + ), + actions=actions, + first_seen_at=now, + last_seen_at=now, + count=1, + data={ + "task_id": task_id, + "auto_decompose": auto_decompose, + "primary_slot": primary_slot, + "main_model_visible": main_visible, + }, + )] + + def _rule_prose_phantom_refs(task, events, runs, now, cfg) -> list[Diagnostic]: """Advisory prose-scan: the completion summary mentions ``t_`` ids that don't resolve. Non-blocking; surfaced as a warning only. @@ -319,18 +531,19 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: all look the same: the kernel keeps retrying and the operator needs to intervene. - Threshold: cfg["failure_threshold"] (default 3). A threshold of 3 - is one below the circuit-breaker's default (5), so the diagnostic - surfaces BEFORE the breaker trips — giving operators a window to - fix the problem while the dispatcher's still retrying. + Threshold: cfg["failure_threshold"]. Runtime callers should derive + this from ``kanban.failure_limit`` unless the user explicitly set a + diagnostics threshold, so the signal does not lag behind the + dispatcher's circuit breaker. Accepts the legacy ``spawn_failure_threshold`` config key for back-compat. """ - threshold = int(cfg.get( + threshold = _positive_int(cfg.get( "failure_threshold", cfg.get("spawn_failure_threshold", 3), - )) + ), 3) + failure_limit = _positive_int(cfg.get("failure_limit"), threshold) # Read the new unified counter name, with a fallback to the legacy # column name so this rule keeps working against old DB rows the # caller somehow materialised without running the migration. @@ -402,10 +615,9 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: f"This task has failed {failures} times in a row " f"(most recent: {outcome_label}). Full last error:\n\n" f"{err_snippet}\n\n" - f"The dispatcher will keep retrying until the consecutive-" - f"failures counter trips the circuit breaker (default 5), " - f"at which point the task auto-blocks. Fix the root cause " - f"and reclaim to retry." + f"The dispatcher circuit breaker is configured for " + f"{failure_limit} consecutive non-success attempts. Fix the " + f"root cause and reclaim or unblock the task to retry." ) else: title = f"Agent {outcome_label} x{failures} (no error recorded)" @@ -427,6 +639,8 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: "consecutive_failures": failures, "most_recent_outcome": most_recent_outcome, "last_error": last_err, + "failure_threshold": threshold, + "failure_limit": failure_limit, }, )] @@ -695,6 +909,7 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: # severity ties. Add new rules here. _RULES: list[RuleFn] = [ _rule_hallucinated_cards, + _rule_triage_aux_unavailable, _rule_prose_phantom_refs, _rule_repeated_failures, _rule_repeated_crashes, @@ -707,6 +922,7 @@ _RULES: list[RuleFn] = [ # rules are added. DIAGNOSTIC_KINDS = ( "hallucinated_cards", + "triage_aux_unavailable", "prose_phantom_refs", "repeated_failures", "repeated_crashes", @@ -716,9 +932,11 @@ DIAGNOSTIC_KINDS = ( DEFAULT_CONFIG = { - "failure_threshold": 3, + # Match the dispatcher default (kanban.failure_limit) so repeated-failure + # diagnostics do not lag behind the default auto-block threshold. + "failure_threshold": 2, # Legacy alias accepted at read time by _rule_repeated_failures. - "spawn_failure_threshold": 3, + "spawn_failure_threshold": 2, "crash_threshold": 2, "blocked_stale_hours": 24, # Stranded-task threshold. 30 min by default — below that, the @@ -728,6 +946,51 @@ DEFAULT_CONFIG = { } +def config_from_kanban_config(kanban_cfg: Optional[dict]) -> dict: + """Build diagnostics config from the runtime ``kanban`` config section. + + ``kanban.diagnostics.failure_threshold`` remains an explicit override. + Otherwise, derive the repeated-failure threshold from + ``kanban.failure_limit`` so CLI/dashboard diagnostics match the + dispatcher's actual circuit-breaker threshold. + """ + kanban_cfg = kanban_cfg or {} + diag_cfg = dict(kanban_cfg.get("diagnostics") or {}) + diag_cfg.setdefault( + "failure_limit", + kanban_cfg.get("failure_limit", DEFAULT_CONFIG["failure_threshold"]), + ) + if ( + "failure_threshold" not in diag_cfg + and "spawn_failure_threshold" not in diag_cfg + ): + diag_cfg["failure_threshold"] = diag_cfg["failure_limit"] + return diag_cfg + + +def config_from_runtime_config(raw_config: Optional[dict]) -> dict: + """Build diagnostics config from the full Hermes runtime config. + + Carries through ``kanban``, ``auxiliary``, and ``model`` keys so triage- + aware rules can inspect the active aux-helper and main-model state. + Folds the ``kanban`` block through ``config_from_kanban_config`` so the + repeated-failure threshold derivation still applies. + """ + raw_config = raw_config or {} + if not isinstance(raw_config, dict): + return {} + cfg: dict = {} + kanban_cfg = raw_config.get("kanban") + if isinstance(kanban_cfg, dict): + cfg.update(config_from_kanban_config(kanban_cfg)) + cfg["kanban"] = kanban_cfg + for key in ("auxiliary", "model"): + value = raw_config.get(key) + if value is not None: + cfg[key] = value + return cfg + + def compute_task_diagnostics( task, events: list, @@ -743,7 +1006,17 @@ def compute_task_diagnostics( most-recent ``last_seen_at``. """ now_ts = int(now if now is not None else time.time()) - cfg = {**DEFAULT_CONFIG, **(config or {})} + config = config or {} + cfg = {**DEFAULT_CONFIG, **config} + if ( + "failure_threshold" not in config + and "spawn_failure_threshold" not in config + and "failure_limit" in config + ): + cfg["failure_threshold"] = _positive_int( + config.get("failure_limit"), + DEFAULT_CONFIG["failure_threshold"], + ) out: list[Diagnostic] = [] for rule in _RULES: try: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index bdb43cc504..06bb8af215 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1761,8 +1761,11 @@ def cmd_setup(args): def cmd_postinstall(args): """One-shot bootstrap for pip users: install non-Python deps + run setup.""" + from hermes_cli.config import stamp_install_method from hermes_cli.dep_ensure import ensure_dependency + stamp_install_method("pip") + print("⚕ Hermes post-install bootstrap") print() diff --git a/model_tools.py b/model_tools.py index 1cbc83096a..ad938b5f18 100644 --- a/model_tools.py +++ b/model_tools.py @@ -788,6 +788,20 @@ def handle_function_call( if block_message is not None: return json.dumps({"error": block_message}, ensure_ascii=False) + # ACP/Zed edit approval runs before any file mutation. The requester + # is bound via ContextVar only for ACP sessions, so CLI/gateway paths + # are unaffected when it is unset. + try: + from acp_adapter.edit_approval import maybe_require_edit_approval + + edit_block_message = maybe_require_edit_approval(function_name, function_args) + if edit_block_message is not None: + return edit_block_message + except Exception as _edit_approval_err: + logger.debug("ACP edit approval guard error: %s", _edit_approval_err) + if function_name in {"write_file", "patch"}: + return json.dumps({"error": "Edit approval denied: approval guard failed"}, ensure_ascii=False) + # Notify the read-loop tracker when a non-read/search tool runs, # so the *consecutive* counter resets (reads after other work are fine). if function_name not in _READ_SEARCH_TOOLS: diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 3f6def61ce..0da0a492a7 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -1548,14 +1548,12 @@ h("div", { className: "flex flex-col gap-1" }, h(Label, { className: "text-xs text-muted-foreground" }, "Orchestrator profile"), - h(Select, { + h(Select, Object.assign({ value: settings.orchestrator_profile || "", className: "h-8", - onChange: function (e) { - const v = (e && e.target ? e.target.value : e) || ""; - saveSettings({ orchestrator_profile: v }); - }, - }, + }, selectChangeHandler(function (v) { + saveSettings({ orchestrator_profile: v }); + })), h(SelectOption, { value: "" }, "(default: " + (settings.active_profile || "default") + ")"), profileOptions, @@ -1566,14 +1564,12 @@ h("div", { className: "flex flex-col gap-1" }, h(Label, { className: "text-xs text-muted-foreground" }, "Default assignee"), - h(Select, { + h(Select, Object.assign({ value: settings.default_assignee || "", className: "h-8", - onChange: function (e) { - const v = (e && e.target ? e.target.value : e) || ""; - saveSettings({ default_assignee: v }); - }, - }, + }, selectChangeHandler(function (v) { + saveSettings({ default_assignee: v }); + })), h(SelectOption, { value: "" }, "(default: " + (settings.active_profile || "default") + ")"), profileOptions, @@ -1701,7 +1697,7 @@ return h("div", { className: "hermes-kanban-boardswitcher" }, h("div", { className: "hermes-kanban-boardswitcher-inner" }, h("div", { className: "flex flex-col gap-0.5" }, - h("div", { className: "text-[11px] uppercase tracking-wider text-muted-foreground" }, + h("div", { className: "text-[11px] tracking-wider text-muted-foreground" }, tx(t, "board", "Board")), h("div", { className: "flex items-center gap-2" }, h(Select, Object.assign({ @@ -2027,11 +2023,10 @@ ), h("div", { className: "hermes-kanban-bulk-reassign", title: "Reassign selected tasks to a different Hermes profile. Pick a profile (or unassign) and click Apply." }, - h(Select, { + h(Select, Object.assign({ value: assignee, - onChange: function (e) { setAssignee(e.target.value); }, className: "h-7 text-xs", - }, + }, selectChangeHandler(setAssignee)), h(SelectOption, { value: "" }, "— reassign —"), h(SelectOption, { value: "__none__" }, "(unassign)"), props.assignees.map(function (a) { @@ -2542,12 +2537,11 @@ className: "h-7 text-xs", }), h("div", { className: "flex gap-2" }, - h(Select, { + h(Select, Object.assign({ value: workspaceKind, - onChange: function (e) { setWorkspaceKind(e.target.value); }, title: "scratch: isolated temp dir (default). worktree: git worktree on the assignee profile. dir: exact path (required below).", className: "h-7 text-xs w-28", - }, + }, selectChangeHandler(setWorkspaceKind)), h(SelectOption, { value: "scratch" }, "scratch"), h(SelectOption, { value: "worktree" }, "worktree"), h(SelectOption, { value: "dir" }, "dir"), @@ -2559,12 +2553,11 @@ className: "h-7 text-xs flex-1", }) : null, ), - h(Select, { + h(Select, Object.assign({ value: parent, - onChange: function (e) { setParent(e.target.value); }, className: "h-7 text-xs", title: "Optional parent task. A child stays blocked in its current column until the parent is marked done.", - }, + }, selectChangeHandler(setParent)), h(SelectOption, { value: "" }, tx(t, "noParent", "— no parent —")), (props.allTasks || []).map(function (task) { return h(SelectOption, { key: task.id, value: task.id }, diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index f3d66a8859..afbe591550 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -465,7 +465,6 @@ .hermes-kanban-section-head { font-size: 0.72rem; font-weight: 600; - text-transform: uppercase; letter-spacing: 0.07em; color: var(--color-muted-foreground); } @@ -611,7 +610,6 @@ } .hermes-kanban-deps-label { font-size: 0.68rem; - text-transform: uppercase; letter-spacing: 0.08em; color: var(--color-muted-foreground); min-width: 4rem; @@ -691,7 +689,6 @@ border: 0; color: var(--color-muted-foreground); font-size: 0.7rem; - text-transform: uppercase; letter-spacing: 0.05em; cursor: pointer; padding: 0; @@ -869,7 +866,6 @@ .hermes-kanban-run-outcome { font-family: var(--font-mono, ui-monospace, monospace); font-weight: 600; - text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-foreground); } @@ -929,7 +925,6 @@ .hermes-kanban-run-meta-label { font-size: 0.65rem; font-weight: 600; - text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-muted-foreground); padding-bottom: 0.15rem; diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 16e6066385..92a9d75366 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -224,6 +224,9 @@ def _compute_task_diagnostics( rule definitions. """ from hermes_cli import kanban_diagnostics as kd + from hermes_cli.config import load_config + + diag_config = kd.config_from_runtime_config(load_config()) # Build the candidate task list. We need each task's row + its # events + its runs. Doing N separate queries works but scales @@ -270,6 +273,7 @@ def _compute_task_diagnostics( r, events_by_task.get(tid, []), runs_by_task.get(tid, []), + config=diag_config, ) if diags: out[tid] = [d.to_dict() for d in diags] diff --git a/pyproject.toml b/pyproject.toml index e5553c2f3f..fc8232ae09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -216,7 +216,7 @@ hermes-acp = "acp_adapter.entry:main" py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils"] [tool.setuptools.package-data] -hermes_cli = ["web_dist/**/*"] +hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"] gateway = ["assets/**/*"] [tool.setuptools.packages.find] diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d3117af1ce..62808e7541 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,4 +1,4 @@ -# ============================================================================ +# ============================================================================ # Hermes Agent Installer for Windows # ============================================================================ # Installation script for Windows (PowerShell). @@ -35,7 +35,11 @@ param( [string]$Stage, [switch]$ProtocolVersion, [switch]$NonInteractive, - [switch]$Json + [switch]$Json, + + # --- Ensure mode (dep_ensure.py entry point) --- + [string]$Ensure = "", + [switch]$PostInstall ) $ErrorActionPreference = "Stop" @@ -115,6 +119,105 @@ function Write-Err { Write-Host "[X] $Message" -ForegroundColor Red } +# --- Ensure-mode helpers --- + +function Resolve-NpmCmd { + $npmCmd = Get-Command npm -ErrorAction SilentlyContinue + if (-not $npmCmd) { return $null } + $npmExe = $npmCmd.Source + if ($npmExe -like "*.ps1") { + $npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd" + if (Test-Path $npmCmdSibling) { return $npmCmdSibling } + } + return $npmExe +} + +function Find-SystemBrowser { + $candidates = @( + "${env:ProgramFiles}\Google\Chrome\Application\chrome.exe", + "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe", + "${env:LOCALAPPDATA}\Google\Chrome\Application\chrome.exe", + "${env:ProgramFiles}\Microsoft\Edge\Application\msedge.exe", + "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe", + "${env:ProgramFiles}\Chromium\Application\chrome.exe", + "${env:LOCALAPPDATA}\Chromium\Application\chrome.exe" + ) + foreach ($p in $candidates) { + if (Test-Path $p) { return $p } + } + return $null +} + +function Write-BrowserEnv { + param([string]$BrowserPath) + if (-not (Test-Path $HermesHome)) { + New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null + } + $envFile = Join-Path $HermesHome ".env" + if (-not (Test-Path $envFile)) { + Set-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8 + return + } + $content = Get-Content $envFile -Raw -ErrorAction SilentlyContinue + if ($content -and $content -match "AGENT_BROWSER_EXECUTABLE_PATH=") { return } + Add-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8 +} + +function Install-AgentBrowser { + param([switch]$SkipChromium) + $npm = Resolve-NpmCmd + if (-not $npm) { + Write-Err "npm not found -- install Node.js first" + throw "npm not found" + } + + Write-Info "Installing agent-browser via npm -g --prefix..." + $prefixDir = Join-Path $HermesHome "node" + if (-not (Test-Path $prefixDir)) { + New-Item -ItemType Directory -Path $prefixDir -Force | Out-Null + } + $npmLog = [System.IO.Path]::GetTempFileName() + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $npm install -g --prefix $prefixDir --silent --ignore-scripts "agent-browser@^0.26.0" "@askjo/camofox-browser@^1.5.2" 2>&1 | Tee-Object -FilePath $npmLog | Out-Null + $npmExit = $LASTEXITCODE + $ErrorActionPreference = $prevEAP + if ($npmExit -ne 0) { + $npmDetail = Get-Content $npmLog -Raw -ErrorAction SilentlyContinue + Remove-Item $npmLog -Force -ErrorAction SilentlyContinue + Write-Err "npm install -g failed (exit $npmExit): $npmDetail" + throw "npm install failed" + } + Remove-Item $npmLog -Force -ErrorAction SilentlyContinue + + if (-not $SkipChromium) { + $sysBrowser = Find-SystemBrowser + if ($sysBrowser) { + Write-BrowserEnv -BrowserPath $sysBrowser + Write-Info "System browser detected -- skipping Chromium download" + } else { + $abExe = Join-Path $prefixDir "agent-browser.cmd" + if (Test-Path $abExe) { + Write-Info "Installing Chromium via agent-browser install..." + $abLog = [System.IO.Path]::GetTempFileName() + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $abExe install 2>&1 | Tee-Object -FilePath $abLog | Out-Null + $abExit = $LASTEXITCODE + $ErrorActionPreference = $prevEAP + if ($abExit -ne 0) { + $abDetail = Get-Content $abLog -Raw -ErrorAction SilentlyContinue + Write-Warn "Chromium install failed (exit $abExit): $abDetail" + } + Remove-Item $abLog -Force -ErrorAction SilentlyContinue + } else { + Write-Warn "agent-browser.cmd not found at $abExe" + } + } + } + Write-Success "Agent-browser ready" +} + # ============================================================================ # Dependency checks # ============================================================================ @@ -2115,6 +2218,48 @@ function Invoke-AllStages { } } +function Invoke-EnsureMode { + param([string]$Deps) + $depList = $Deps -split "," + foreach ($dep in $depList) { + $dep = $dep.Trim() + switch ($dep) { + "node" { + [void](Test-Node) + if (-not $script:HasNode) { + Write-Err "Node.js could not be installed" + exit 1 + } + } + "browser" { + [void](Test-Node) + if ($script:HasNode) { + Install-AgentBrowser + } else { + Write-Err "Node.js is required for browser tools but could not be installed" + exit 1 + } + } + "ripgrep" { + Write-Info "ripgrep: install manually on Windows (scoop install ripgrep)" + } + "ffmpeg" { + Write-Info "ffmpeg: install manually on Windows (scoop install ffmpeg)" + } + default { + Write-Err "Unknown dependency: $dep" + exit 1 + } + } + } +} + +function Invoke-PostInstallMode { + Write-Info "Running post-install setup..." + Invoke-EnsureMode -Deps "node,browser" + Write-Info "Post-install complete" +} + function Main { Write-Banner Invoke-AllStages @@ -2134,6 +2279,19 @@ function Main { # structured JSON error frame instead of a bare exception. try { + if ($Ensure -ne "") { + if ($PSBoundParameters.ContainsKey("Stage")) { + Write-Err "Cannot use -Ensure and -Stage simultaneously" + exit 1 + } + Invoke-EnsureMode -Deps $Ensure + exit 0 + } + if ($PostInstall) { + Invoke-PostInstallMode + exit 0 + } + if ($ProtocolVersion) { Write-Output $InstallStageProtocolVersion exit 0 diff --git a/scripts/install.sh b/scripts/install.sh index 9b1b7469bb..3ece561a86 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1512,6 +1512,17 @@ find_system_browser() { fi done + if [ "$(uname)" = "Darwin" ]; then + for app in \ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "/Applications/Chromium.app/Contents/MacOS/Chromium"; do + if [ -x "$app" ]; then + echo "$app" + return 0 + fi + done + fi + return 1 } @@ -1534,10 +1545,15 @@ configure_browser_env_from_system_browser() { browser_path="$(find_system_browser 2>/dev/null || true)" fi - if [ -z "$browser_path" ] || [ ! -f "$env_file" ]; then + if [ -z "$browser_path" ]; then return 0 fi + mkdir -p "$HERMES_HOME" + if [ ! -f "$env_file" ]; then + touch "$env_file" + fi + if grep -q '^AGENT_BROWSER_EXECUTABLE_PATH=' "$env_file" 2>/dev/null; then log_info "AGENT_BROWSER_EXECUTABLE_PATH already configured" return 0 @@ -1888,6 +1904,73 @@ print_success() { fi } +ensure_browser() { + if ! command -v node >/dev/null 2>&1; then + local node_bin="$HERMES_HOME/node/bin/node" + if [ -x "$node_bin" ]; then + export PATH="$HERMES_HOME/node/bin:$PATH" + else + log_error "Node.js not found. Run with --ensure node first." + return 1 + fi + fi + + local npm_bin + npm_bin="$(command -v npm 2>/dev/null || echo "$HERMES_HOME/node/bin/npm")" + if [ ! -x "$npm_bin" ]; then + log_error "npm not found" + return 1 + fi + + log_info "Installing agent-browser..." + local log_file + log_file="$(mktemp)" + if ! "$npm_bin" install -g --prefix "$HERMES_HOME/node" --silent --ignore-scripts \ + "agent-browser@^0.26.0" \ + "@askjo/camofox-browser@^1.5.2" \ + >"$log_file" 2>&1; then + log_error "npm install failed:" + cat "$log_file" >&2 + rm -f "$log_file" + return 1 + fi + rm -f "$log_file" + export PATH="$HERMES_HOME/node/bin:$PATH" + + local sys_browser + sys_browser="$(find_system_browser 2>/dev/null || true)" + if [ -n "$sys_browser" ]; then + configure_browser_env_from_system_browser "$sys_browser" + log_info "System browser detected -- skipping Chromium download" + return 0 + fi + + log_info "Installing Chromium via agent-browser install..." + local ab_bin="$HERMES_HOME/node/bin/agent-browser" + if [ -x "$ab_bin" ]; then + "$ab_bin" install 2>/dev/null || { + log_warn "Chromium install failed. Browser tools may not work without a system browser." + + # OS-specific hints (detect_os sets $DISTRO) + case "${DISTRO:-unknown}" in + ubuntu|debian) + log_info "Try: sudo apt-get install -y chromium-browser" + ;; + arch) + log_info "Try: sudo pacman -S chromium" + ;; + fedora|rhel|centos) + log_info "Try: sudo dnf install -y chromium" + ;; + esac + } + else + log_warn "agent-browser not found at $ab_bin" + fi + + return 0 +} + ensure_mode() { detect_os @@ -1901,19 +1984,7 @@ ensure_mode() { browser) check_node if [ "$HAS_NODE" = true ]; then - DETECTED_BROWSER_EXECUTABLE="$(find_system_browser 2>/dev/null || true)" - if [ -z "$DETECTED_BROWSER_EXECUTABLE" ]; then - log_info "Installing agent-browser + Chromium..." - npm_bin="$(command -v npm 2>/dev/null || echo "")" - if [ -n "$npm_bin" ]; then - local agent_browser_dir="$HERMES_HOME/node_modules" - mkdir -p "$agent_browser_dir" - "$npm_bin" install --prefix "$HERMES_HOME" agent-browser 2>/dev/null || true - npx playwright install chromium 2>/dev/null || true - fi - else - log_success "System browser found: $DETECTED_BROWSER_EXECUTABLE" - fi + ensure_browser fi ;; ripgrep) @@ -1948,16 +2019,7 @@ postinstall_mode() { install_system_packages if [ "$HAS_NODE" = true ] && [ "$SKIP_BROWSER" = false ]; then - DETECTED_BROWSER_EXECUTABLE="$(find_system_browser 2>/dev/null || true)" - if [ -z "$DETECTED_BROWSER_EXECUTABLE" ]; then - log_info "Installing browser engine..." - npm_bin="$(command -v npm 2>/dev/null || echo "")" - if [ -n "$npm_bin" ]; then - npx playwright install chromium 2>/dev/null || true - fi - else - log_success "System browser found: $DETECTED_BROWSER_EXECUTABLE" - fi + ensure_browser fi HERMES_CMD="$(command -v hermes 2>/dev/null || echo "")" @@ -1996,6 +2058,8 @@ main() { maybe_start_gateway print_success + + echo "git" > "$HERMES_HOME/.install_method" } if [ -n "$ENSURE_DEPS" ]; then diff --git a/tests/acp/test_edit_approval.py b/tests/acp/test_edit_approval.py new file mode 100644 index 0000000000..1b6660c3b2 --- /dev/null +++ b/tests/acp/test_edit_approval.py @@ -0,0 +1,203 @@ +"""Tests for ACP pre-edit approval gating.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from acp_adapter.edit_approval import ( + EditProposal, + build_acp_edit_tool_call, + clear_edit_approval_requester, + set_edit_approval_requester, + should_auto_approve_edit, +) +from model_tools import handle_function_call + + +def teardown_function() -> None: + clear_edit_approval_requester() + + +def test_acp_permission_tool_call_uses_edit_kind_and_diff_content(): + proposal = EditProposal( + tool_name="write_file", + path="demo.txt", + old_text="old\n", + new_text="new\n", + arguments={"path": "demo.txt", "content": "new\n"}, + ) + + tool_call = build_acp_edit_tool_call(proposal) + + assert tool_call.kind == "edit" + assert tool_call.status == "pending" + assert tool_call.rawInput == {"tool": "write_file", "arguments": proposal.arguments} + assert len(tool_call.content) == 1 + diff = tool_call.content[0] + assert diff.path == "demo.txt" + assert diff.oldText == "old\n" + assert diff.newText == "new\n" + + +def test_write_file_rejection_does_not_mutate_existing_file(tmp_path): + target = tmp_path / "sample.txt" + target.write_text("before\n", encoding="utf-8") + + set_edit_approval_requester(lambda _proposal: False) + + result = json.loads( + handle_function_call( + "write_file", + {"path": str(target), "content": "after\n"}, + task_id="acp-edit-reject", + ) + ) + + assert "error" in result + assert "Edit approval denied" in result["error"] + assert target.read_text(encoding="utf-8") == "before\n" + + +def test_write_file_approval_mutates_and_request_includes_diff(tmp_path): + target = tmp_path / "sample.txt" + target.write_text("before\n", encoding="utf-8") + proposals = [] + + def approve(proposal): + proposals.append(proposal) + return True + + set_edit_approval_requester(approve) + + result = json.loads( + handle_function_call( + "write_file", + {"path": str(target), "content": "after\n"}, + task_id="acp-edit-approve", + ) + ) + + assert result.get("bytes_written") == len("after\n") + assert target.read_text(encoding="utf-8") == "after\n" + assert len(proposals) == 1 + proposal = proposals[0] + assert proposal.tool_name == "write_file" + assert proposal.path == str(target) + assert proposal.old_text == "before\n" + assert proposal.new_text == "after\n" + + +def test_write_file_new_file_request_has_empty_old_text(tmp_path): + target = tmp_path / "new.txt" + proposals = [] + + set_edit_approval_requester(lambda proposal: proposals.append(proposal) or True) + + result = json.loads( + handle_function_call( + "write_file", + {"path": str(target), "content": "created\n"}, + task_id="acp-edit-new-file", + ) + ) + + assert result.get("bytes_written") == len("created\n") + assert target.read_text(encoding="utf-8") == "created\n" + assert proposals[0].old_text is None + assert proposals[0].new_text == "created\n" + + +def test_requester_exception_denies_and_does_not_mutate(tmp_path): + target = tmp_path / "sample.txt" + target.write_text("before\n", encoding="utf-8") + + def boom(_proposal): + raise RuntimeError("zed disconnected") + + set_edit_approval_requester(boom) + + result = json.loads( + handle_function_call( + "write_file", + {"path": str(target), "content": "after\n"}, + task_id="acp-edit-exception", + ) + ) + + assert "error" in result + assert "Edit approval denied" in result["error"] + assert target.read_text(encoding="utf-8") == "before\n" + + +def test_patch_replace_rejection_does_not_mutate(tmp_path): + target = tmp_path / "sample.txt" + target.write_text("alpha\nbeta\n", encoding="utf-8") + + set_edit_approval_requester(lambda _proposal: False) + + result = json.loads( + handle_function_call( + "patch", + { + "mode": "replace", + "path": str(target), + "old_string": "beta\n", + "new_string": "gamma\n", + }, + task_id="acp-patch-reject", + ) + ) + + assert "error" in result + assert "Edit approval denied" in result["error"] + assert target.read_text(encoding="utf-8") == "alpha\nbeta\n" + + +def test_patch_replace_approval_request_includes_full_file_diff(tmp_path): + target = tmp_path / "sample.txt" + target.write_text("alpha\nbeta\n", encoding="utf-8") + proposals = [] + + set_edit_approval_requester(lambda proposal: proposals.append(proposal) or True) + + result = json.loads( + handle_function_call( + "patch", + { + "mode": "replace", + "path": str(target), + "old_string": "beta\n", + "new_string": "gamma\n", + }, + task_id="acp-patch-approve", + ) + ) + + assert result.get("success") is True + assert target.read_text(encoding="utf-8") == "alpha\ngamma\n" + assert proposals[0].tool_name == "patch" + assert proposals[0].old_text == "alpha\nbeta\n" + assert proposals[0].new_text == "alpha\ngamma\n" + + +def test_workspace_auto_approval_allows_workspace_and_tmp_but_not_sensitive(tmp_path): + workspace_file = tmp_path / "src.py" + tmp_file = Path("/tmp/hermes-acp-auto-approve-test.txt") + env_file = tmp_path / ".env" + + assert should_auto_approve_edit( + EditProposal("write_file", str(workspace_file), None, "x", {}), + "workspace_session", + str(tmp_path), + ) + assert should_auto_approve_edit( + EditProposal("write_file", str(tmp_file), None, "x", {}), + "workspace_session", + str(tmp_path), + ) + assert not should_auto_approve_edit( + EditProposal("write_file", str(env_file), None, "SECRET=x", {}), + "session", + str(tmp_path), + ) diff --git a/tests/acp/test_entry.py b/tests/acp/test_entry.py index 81d30cd868..1d881565bd 100644 --- a/tests/acp/test_entry.py +++ b/tests/acp/test_entry.py @@ -94,103 +94,62 @@ def test_main_setup_skips_browser_prompt_on_no(monkeypatch): assert called == [] -def test_main_setup_browser_invokes_bundled_script(monkeypatch): - """`hermes-acp --setup-browser` must shell out to the bundled bootstrap - script — never reimplement the install logic inline.""" - monkeypatch.setattr("platform.system", lambda: "Linux") +def test_main_setup_browser_calls_ensure_dependency(monkeypatch): + """`hermes-acp --setup-browser` routes through dep_ensure.ensure_dependency.""" + calls = [] - captured = {} + def fake_ensure(dep, interactive=True): + calls.append((dep, interactive)) + return True - def fake_run(cmd, check=False): - captured["cmd"] = cmd - - class _R: - returncode = 0 - - return _R() - - monkeypatch.setattr("subprocess.run", fake_run) + monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure) entry.main(["--setup-browser"]) - assert captured["cmd"][0] == "bash" - assert captured["cmd"][1].endswith("bootstrap_browser_tools.sh") - # --yes is NOT passed when the flag is absent. - assert "--yes" not in captured["cmd"] + assert ("node", True) in calls + assert ("browser", True) in calls def test_main_setup_browser_forwards_yes_flag(monkeypatch): - monkeypatch.setattr("platform.system", lambda: "Linux") + """--yes suppresses interactive prompts in ensure_dependency.""" + calls = [] - captured = {} + def fake_ensure(dep, interactive=True): + calls.append((dep, interactive)) + return True - def fake_run(cmd, check=False): - captured["cmd"] = cmd - - class _R: - returncode = 0 - - return _R() - - monkeypatch.setattr("subprocess.run", fake_run) + monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure) entry.main(["--setup-browser", "--yes"]) - assert "--yes" in captured["cmd"] + assert ("node", False) in calls + assert ("browser", False) in calls -def test_main_setup_browser_uses_powershell_on_windows(monkeypatch): - monkeypatch.setattr("platform.system", lambda: "Windows") +def test_main_setup_browser_stops_on_node_failure(monkeypatch): + """If node install fails, browser install is not attempted.""" + calls = [] - captured = {} + def fake_ensure(dep, interactive=True): + calls.append(dep) + return dep != "node" # node fails - def fake_run(cmd, check=False): - captured["cmd"] = cmd - - class _R: - returncode = 0 - - return _R() - - monkeypatch.setattr("subprocess.run", fake_run) - - entry.main(["--setup-browser", "--yes"]) - - assert captured["cmd"][0] == "powershell.exe" - assert any(part.endswith("bootstrap_browser_tools.ps1") for part in captured["cmd"]) - assert "-Yes" in captured["cmd"] - - -def test_main_setup_browser_propagates_failure(monkeypatch): - monkeypatch.setattr("platform.system", lambda: "Linux") - - class _R: - returncode = 7 - - monkeypatch.setattr("subprocess.run", lambda cmd, check=False: _R()) + monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure) with pytest.raises(SystemExit) as excinfo: entry.main(["--setup-browser"]) - assert excinfo.value.code == 7 + assert excinfo.value.code == 1 + assert "node" in calls + assert "browser" not in calls -def test_bootstrap_scripts_ship_with_package(): - """The package-data wiring (pyproject.toml) must include the bootstrap - scripts — otherwise `--setup-browser` 404s at runtime.""" - from pathlib import Path +def test_main_setup_browser_propagates_browser_failure(monkeypatch): + """If browser install fails, exit code is 1.""" + def fake_ensure(dep, interactive=True): + return dep != "browser" # browser fails - bootstrap_dir = Path(entry.__file__).resolve().parent / "bootstrap" - sh = bootstrap_dir / "bootstrap_browser_tools.sh" - ps1 = bootstrap_dir / "bootstrap_browser_tools.ps1" + monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure) - assert sh.is_file(), f"missing bundled script: {sh}" - assert ps1.is_file(), f"missing bundled script: {ps1}" - - sh_text = sh.read_text(encoding="utf-8") - ps1_text = ps1.read_text(encoding="utf-8") - - # Sanity: scripts know how to find the Hermes-managed Node prefix. - assert "HERMES_HOME" in sh_text - assert "agent-browser" in sh_text - assert "HermesHome" in ps1_text - assert "agent-browser" in ps1_text + with pytest.raises(SystemExit) as excinfo: + entry.main(["--setup-browser"]) + assert excinfo.value.code == 1 diff --git a/tests/acp/test_mcp_e2e.py b/tests/acp/test_mcp_e2e.py index dab4607198..00bf53b21f 100644 --- a/tests/acp/test_mcp_e2e.py +++ b/tests/acp/test_mcp_e2e.py @@ -183,7 +183,7 @@ class TestMcpRegistrationE2E: assert "hello" in complete_event.content[0].content.text assert complete_event.raw_output is None - def test_patch_mode_tool_start_emits_diff_blocks_for_v4a_patch(self): + def test_patch_mode_tool_start_defers_diff_to_edit_approval_prompt(self): update = build_tool_start( "tc-1", "patch", @@ -193,14 +193,9 @@ class TestMcpRegistrationE2E: }, ) - assert len(update.content) == 2 - assert update.content[0].type == "diff" - assert update.content[0].path == "src/app.py" - assert update.content[0].old_text == "old line" - assert update.content[0].new_text == "new line" - assert update.content[1].type == "diff" - assert update.content[1].path == "src/new.py" - assert update.content[1].new_text == "hello" + assert len(update.content) == 1 + assert update.content[0].type == "content" + assert "Approval prompt shows the diff" in update.content[0].content.text @pytest.mark.asyncio async def test_prompt_tool_results_paired_by_call_id(self, acp_agent, mock_manager): diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index 65dd6fd6b7..79b7e56f2b 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -24,6 +24,7 @@ from acp.schema import ( PromptResponse, ResumeSessionResponse, SessionModelState, + SessionModeState, SetSessionConfigOptionResponse, SetSessionModelResponse, SetSessionModeResponse, @@ -53,6 +54,35 @@ def agent(mock_manager): return HermesACPAgent(session_manager=mock_manager) +@pytest.mark.asyncio +async def test_new_session_exposes_edit_approvals_as_modes_not_config_options(agent): + resp = await agent.new_session(cwd="/tmp") + + assert resp.config_options is None + assert isinstance(resp.modes, SessionModeState) + assert resp.modes.current_mode_id == "default" + assert [(mode.id, mode.name) for mode in resp.modes.available_modes] == [ + ("default", "Default"), + ("accept_edits", "Accept Edits"), + ("dont_ask", "Don't Ask"), + ] + + +@pytest.mark.asyncio +async def test_set_config_option_persists_edit_approval_policy_without_advertising_config(agent): + resp = await agent.new_session(cwd="/tmp") + update = await agent.set_config_option( + "edit_approval_policy", + resp.session_id, + "workspace_session", + ) + state = agent.session_manager.get_session(resp.session_id) + + assert isinstance(update, SetSessionConfigOptionResponse) + assert update.config_options == [] + assert getattr(state, "mode", None) == "accept_edits" + + # --------------------------------------------------------------------------- # initialize # --------------------------------------------------------------------------- @@ -865,11 +895,11 @@ class TestSessionConfiguration: @pytest.mark.asyncio async def test_set_session_mode_returns_response(self, agent): new_resp = await agent.new_session(cwd="/tmp") - resp = await agent.set_session_mode(mode_id="chat", session_id=new_resp.session_id) + resp = await agent.set_session_mode(mode_id="accept_edits", session_id=new_resp.session_id) state = agent.session_manager.get_session(new_resp.session_id) assert isinstance(resp, SetSessionModeResponse) - assert getattr(state, "mode", None) == "chat" + assert getattr(state, "mode", None) == "accept_edits" @pytest.mark.asyncio async def test_router_accepts_stable_session_config_methods(self, agent): @@ -878,7 +908,7 @@ class TestSessionConfiguration: mode_result = await router( "session/set_mode", - {"modeId": "chat", "sessionId": new_resp.session_id}, + {"modeId": "accept_edits", "sessionId": new_resp.session_id}, False, ) config_result = await router( @@ -892,7 +922,7 @@ class TestSessionConfiguration: ) assert mode_result == {} - assert config_result == {"configOptions": []} + assert config_result["configOptions"] == [] @pytest.mark.asyncio async def test_router_accepts_unstable_model_switch_when_enabled(self, agent): diff --git a/tests/acp/test_tools.py b/tests/acp/test_tools.py index dc62b296c6..004b1f32f8 100644 --- a/tests/acp/test_tools.py +++ b/tests/acp/test_tools.py @@ -2,6 +2,7 @@ import pytest +from acp_adapter.edit_approval import EditProposal from acp_adapter.tools import ( TOOL_KIND_MAP, build_tool_complete, @@ -147,7 +148,7 @@ class TestBuildToolTitle: class TestBuildToolStart: def test_build_tool_start_for_patch(self): - """patch should produce a FileEditToolCallContent (diff).""" + """patch start should not duplicate the edit-approval diff.""" args = { "path": "src/main.py", "old_string": "print('hello')", @@ -156,24 +157,42 @@ class TestBuildToolStart: result = build_tool_start("tc-1", "patch", args) assert isinstance(result, ToolCallStart) assert result.kind == "edit" - # The first content item should be a diff assert len(result.content) >= 1 - diff_item = result.content[0] - assert isinstance(diff_item, FileEditToolCallContent) - assert diff_item.path == "src/main.py" - assert diff_item.new_text == "print('world')" - assert diff_item.old_text == "print('hello')" + item = result.content[0] + assert isinstance(item, ContentToolCallContent) + assert "Approval prompt shows the diff" in item.content.text + assert "src/main.py" in item.content.text def test_build_tool_start_for_write_file(self): - """write_file should produce a FileEditToolCallContent (diff).""" + """write_file start should not duplicate the edit-approval diff.""" args = {"path": "new_file.py", "content": "print('hello')"} result = build_tool_start("tc-w1", "write_file", args) assert isinstance(result, ToolCallStart) assert result.kind == "edit" assert len(result.content) >= 1 - diff_item = result.content[0] - assert isinstance(diff_item, FileEditToolCallContent) - assert diff_item.path == "new_file.py" + item = result.content[0] + assert isinstance(item, ContentToolCallContent) + assert "Approval prompt shows the diff" in item.content.text + assert "new_file.py" in item.content.text + + def test_auto_approved_edit_start_shows_diff_content(self): + """Auto-approved edit starts need the diff because no approval card exists.""" + args = {"path": "/tmp/acp.txt", "old_string": "old", "new_string": "new"} + result = build_tool_start( + "tc-auto-edit", + "patch", + args, + edit_diff=EditProposal("patch", "/tmp/acp.txt", "old\n", "new\n", args), + ) + + assert isinstance(result, ToolCallStart) + assert result.kind == "edit" + assert len(result.content) == 1 + item = result.content[0] + assert isinstance(item, FileEditToolCallContent) + assert item.path == "/tmp/acp.txt" + assert item.old_text == "old\n" + assert item.new_text == "new\n" def test_build_tool_start_for_terminal(self): """terminal should produce text content with the command.""" @@ -452,8 +471,8 @@ class TestBuildToolComplete: assert len(display_text) < 6000 assert "truncated" in display_text - def test_build_tool_complete_for_patch_uses_diff_blocks(self): - """Completed patch calls should keep structured diff content for Zed.""" + def test_build_tool_complete_for_patch_summarizes_without_repeating_diff(self): + """Completed patch calls should not duplicate the edit-approval diff.""" patch_result = ( '{"success": true, "diff": "--- a/README.md\\n+++ b/README.md\\n@@ -1 +1,2 @@\\n old line\\n+new line\\n", ' '"files_modified": ["README.md"]}' @@ -461,18 +480,17 @@ class TestBuildToolComplete: result = build_tool_complete("tc-p1", "patch", patch_result) assert isinstance(result, ToolCallProgress) assert len(result.content) == 1 - diff_item = result.content[0] - assert isinstance(diff_item, FileEditToolCallContent) - assert diff_item.path == "README.md" - assert diff_item.old_text == "old line" - assert diff_item.new_text == "old line\nnew line" + item = result.content[0] + assert isinstance(item, ContentToolCallContent) + assert "✅ patch completed" in item.content.text + assert "README.md" in item.content.text def test_build_tool_complete_for_patch_falls_back_to_text_when_no_diff(self): result = build_tool_complete("tc-p2", "patch", '{"success": true}') assert isinstance(result, ToolCallProgress) assert isinstance(result.content[0], ContentToolCallContent) - def test_build_tool_complete_for_write_file_uses_snapshot_diff(self, tmp_path): + def test_build_tool_complete_for_write_file_summarizes_without_repeating_diff(self, tmp_path): target = tmp_path / "diff-test.txt" snapshot = type("Snapshot", (), {"paths": [target], "before": {str(target): None}})() target.write_text("hello from hermes\n", encoding="utf-8") @@ -486,11 +504,10 @@ class TestBuildToolComplete: ) assert isinstance(result, ToolCallProgress) assert len(result.content) == 1 - diff_item = result.content[0] - assert isinstance(diff_item, FileEditToolCallContent) - assert diff_item.path.endswith("diff-test.txt") - assert diff_item.old_text is None - assert diff_item.new_text == "hello from hermes" + item = result.content[0] + assert isinstance(item, ContentToolCallContent) + assert "✅ write_file completed" in item.content.text + assert "diff-test.txt" in item.content.text # --------------------------------------------------------------------------- diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index c7119dfd3b..3d19c32dca 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -155,6 +155,27 @@ class TestBuildAnthropicClient: "anthropic-beta": "interleaved-thinking-2025-05-14" } + def test_azure_foundry_anthropic_endpoint_uses_bearer_auth(self): + """Azure AI Foundry's /anthropic endpoint requires Authorization: Bearer. + + Regression test for #26970: without this, builds set api_key (x-api-key) + and the endpoint returns HTTP 401. Also verifies that Azure retains the + 1M-context beta even though it now matches `_requires_bearer_auth`. + """ + with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + build_anthropic_client( + "azure-foundry-secret-123", + base_url="https://my-resource.openai.azure.com/anthropic", + ) + kwargs = mock_sdk.Anthropic.call_args[1] + assert kwargs["auth_token"] == "azure-foundry-secret-123" + assert "api_key" not in kwargs + # Azure endpoints still get the api-version query param plumbing. + assert kwargs.get("default_query") == {"api-version": "2025-04-15"} + # Azure keeps the 1M-context beta (it's not MiniMax). + betas = kwargs["default_headers"]["anthropic-beta"] + assert "context-1m-2025-08-07" in betas + class TestReadClaudeCodeCredentials: @pytest.fixture(autouse=True) diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py index c11976ef97..a206348c0d 100644 --- a/tests/agent/test_skill_commands.py +++ b/tests/agent/test_skill_commands.py @@ -4,6 +4,8 @@ import os from pathlib import Path from unittest.mock import patch +import pytest + import tools.skills_tool as skills_tool_module from agent.skill_commands import ( build_preloaded_skills_prompt, @@ -125,6 +127,30 @@ class TestScanSkillCommands: assert "/knowledge-brain" in result assert result["/knowledge-brain"]["name"] == "knowledge-brain" + def test_loads_skill_invocation_from_symlinked_skill_dir(self, tmp_path): + """Slash commands should load skills symlinked under the local skills dir.""" + external_root = tmp_path / "external" + skills_root = tmp_path / "skills" + skills_root.mkdir() + real_skill_dir = _make_skill( + external_root, + "impeccable", + body="Apply impeccable design craft.", + ) + symlink_path = skills_root / "impeccable" + try: + symlink_path.symlink_to(real_skill_dir, target_is_directory=True) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"symlinks unavailable in test environment: {exc}") + + with patch("tools.skills_tool.SKILLS_DIR", skills_root): + result = scan_skill_commands() + message = build_skill_invocation_message("/impeccable") + + assert "/impeccable" in result + assert message is not None + assert "Apply impeccable design craft." in message + def test_get_skill_commands_rescans_when_platform_scope_changes(self, tmp_path): """Platform-specific disabled-skill caches must not leak across platforms. diff --git a/tests/gateway/test_extract_local_files.py b/tests/gateway/test_extract_local_files.py index dd93e6370f..568b311cb9 100644 --- a/tests/gateway/test_extract_local_files.py +++ b/tests/gateway/test_extract_local_files.py @@ -74,6 +74,58 @@ class TestBasicDetection: assert len(paths) == 1, f"Failed for {ext}" assert paths[0] == f"/tmp/pic{ext}" + def test_document_extensions(self): + """Documents (PDF, Word, plain text, etc.) ship as file uploads.""" + for ext in (".pdf", ".docx", ".doc", ".odt", ".rtf", ".txt", ".md"): + text = f"Report at /tmp/report{ext} attached" + paths, _ = _extract(text) + assert len(paths) == 1, f"Failed for {ext}" + assert paths[0] == f"/tmp/report{ext}" + + def test_spreadsheet_and_data_extensions(self): + """Spreadsheets and structured data ship as file uploads.""" + for ext in (".xlsx", ".xls", ".csv", ".tsv", ".json", ".xml", ".yaml", ".yml"): + text = f"Data at /tmp/data{ext} ready" + paths, _ = _extract(text) + assert len(paths) == 1, f"Failed for {ext}" + assert paths[0] == f"/tmp/data{ext}" + + def test_presentation_extensions(self): + """Presentations ship as file uploads.""" + for ext in (".pptx", ".ppt", ".odp"): + text = f"Deck at /tmp/deck{ext} done" + paths, _ = _extract(text) + assert len(paths) == 1, f"Failed for {ext}" + assert paths[0] == f"/tmp/deck{ext}" + + def test_audio_extensions(self): + """Audio files are detected and routed by the gateway dispatch.""" + for ext in (".mp3", ".wav", ".ogg", ".m4a", ".flac"): + text = f"Audio at /tmp/sound{ext} ready" + paths, _ = _extract(text) + assert len(paths) == 1, f"Failed for {ext}" + assert paths[0] == f"/tmp/sound{ext}" + + def test_archive_extensions(self): + """Archives ship as file uploads.""" + for ext in (".zip", ".tar", ".gz", ".tgz", ".bz2", ".7z"): + text = f"Archive at /tmp/bundle{ext} ready" + paths, _ = _extract(text) + assert len(paths) == 1, f"Failed for {ext}" + assert paths[0] == f"/tmp/bundle{ext}" + + def test_html_extension(self): + paths, _ = _extract("Open /tmp/report.html in browser") + assert paths == ["/tmp/report.html"] + + def test_chart_pdf_path(self): + """Common case: agent renders a chart via matplotlib and references the file.""" + text = "Here is the comparison chart: /tmp/q3-sales.pdf" + paths, cleaned = _extract(text) + assert paths == ["/tmp/q3-sales.pdf"] + assert "/tmp/q3-sales.pdf" not in cleaned + assert "comparison chart" in cleaned + def test_case_insensitive_extension(self): paths, _ = _extract("See /tmp/PHOTO.PNG and /tmp/vid.MP4 now") assert len(paths) == 2 @@ -269,8 +321,15 @@ class TestEdgeCases: assert cleaned == "" def test_no_media_extensions(self): - """Non-media extensions should not be matched.""" - paths, _ = _extract("See /tmp/data.csv and /tmp/script.py and /tmp/notes.txt") + """Extensions outside the supported list should not be matched. + + ``.py`` and ``.log`` are intentionally excluded because (a) most + source files are quoted in inline code or fenced blocks anyway, + and (b) auto-shipping arbitrary source files would be a + surprise. Documents (.pdf, .docx), data (.csv, .json), + archives (.zip), and presentations (.pptx) ARE matched. + """ + paths, _ = _extract("See /tmp/script.py and /tmp/server.log here") assert paths == [] def test_path_with_spaces_not_matched(self): diff --git a/tests/hermes_cli/test_dep_ensure.py b/tests/hermes_cli/test_dep_ensure.py index c980c29009..77fee5b7ec 100644 --- a/tests/hermes_cli/test_dep_ensure.py +++ b/tests/hermes_cli/test_dep_ensure.py @@ -16,7 +16,7 @@ def test_ensure_dependency_returns_false_when_missing_noninteractive(): from hermes_cli.dep_ensure import ensure_dependency with patch("hermes_cli.dep_ensure.shutil") as mock_shutil: mock_shutil.which.return_value = None - with patch("hermes_cli.dep_ensure._find_install_script", return_value=None): + with patch("hermes_cli.dep_ensure._find_install_script", return_value=(None, None)): result = ensure_dependency("node", interactive=False) assert result is False @@ -27,9 +27,11 @@ def test_find_install_script_from_checkout(tmp_path): scripts_dir = tmp_path / "scripts" scripts_dir.mkdir() (scripts_dir / "install.sh").write_text("#!/bin/bash", encoding="utf-8") - result = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) - assert result is not None - assert result.name == "install.sh" + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) + assert path is not None + assert path.name == "install.sh" + assert shell == "bash" def test_find_install_script_from_wheel(tmp_path): @@ -38,6 +40,124 @@ def test_find_install_script_from_wheel(tmp_path): bundled = tmp_path / "hermes_cli" / "scripts" bundled.mkdir(parents=True) (bundled / "install.sh").write_text("#!/bin/bash", encoding="utf-8") - result = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) - assert result is not None - assert result.name == "install.sh" + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) + assert path is not None + assert path.name == "install.sh" + assert shell == "bash" + + +def test_find_install_script_prefers_ps1_on_windows(tmp_path): + """On Windows, _find_install_script should find install.ps1.""" + scripts_dir = tmp_path / "hermes_cli" / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "install.ps1").write_text("# fake") + (scripts_dir / "install.sh").write_text("# fake") + from hermes_cli.dep_ensure import _find_install_script + with patch("hermes_cli.dep_ensure._IS_WINDOWS", True): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli") + assert path == scripts_dir / "install.ps1" + assert shell == "powershell" + + +def test_find_install_script_returns_sh_on_posix(tmp_path): + """On POSIX, _find_install_script should find install.sh.""" + scripts_dir = tmp_path / "hermes_cli" / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "install.ps1").write_text("# fake") + (scripts_dir / "install.sh").write_text("# fake") + from hermes_cli.dep_ensure import _find_install_script + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli") + assert path == scripts_dir / "install.sh" + assert shell == "bash" + + +def test_find_install_script_falls_back_to_repo_root(tmp_path): + """When no bundled script, check repo root.""" + repo_root = tmp_path / "repo" + (repo_root / "scripts").mkdir(parents=True) + (repo_root / "scripts" / "install.sh").write_text("# fake") + from hermes_cli.dep_ensure import _find_install_script + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=repo_root) + assert path == repo_root / "scripts" / "install.sh" + assert shell == "bash" + + +def test_find_install_script_returns_none_when_missing(tmp_path): + from hermes_cli.dep_ensure import _find_install_script + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + result = _find_install_script(package_dir=tmp_path / "x", repo_root=tmp_path / "y") + assert result == (None, None) + + +def test_has_system_browser_checks_windows_names(): + from hermes_cli.dep_ensure import _has_system_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", True), \ + patch("hermes_cli.dep_ensure.shutil") as mock_shutil: + mock_shutil.which.side_effect = lambda name: "/fake/msedge.exe" if name == "msedge" else None + assert _has_system_browser() is True + + +def test_has_system_browser_checks_posix_names(): + from hermes_cli.dep_ensure import _has_system_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False), \ + patch("hermes_cli.dep_ensure.shutil") as mock_shutil: + mock_shutil.which.return_value = None + assert _has_system_browser() is False + + +def test_has_hermes_agent_browser_windows_path(tmp_path): + node_dir = tmp_path / "node" + node_dir.mkdir(parents=True) + (node_dir / "agent-browser.cmd").write_text("@echo off") + from hermes_cli.dep_ensure import _has_hermes_agent_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", True), \ + patch("hermes_constants.get_hermes_home", return_value=tmp_path): + assert _has_hermes_agent_browser() is True + + +def test_has_hermes_agent_browser_posix_path(tmp_path): + bin_dir = tmp_path / "node" / "bin" + bin_dir.mkdir(parents=True) + (bin_dir / "agent-browser").write_text("#!/bin/sh") + from hermes_cli.dep_ensure import _has_hermes_agent_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False), \ + patch("hermes_constants.get_hermes_home", return_value=tmp_path): + assert _has_hermes_agent_browser() is True + + +def test_has_hermes_agent_browser_legacy_node_modules_path(tmp_path): + """Legacy git-clone installs put agent-browser in $HERMES_HOME/node_modules/.bin/.""" + bin_dir = tmp_path / "node_modules" / ".bin" + bin_dir.mkdir(parents=True) + (bin_dir / "agent-browser").write_text("#!/bin/sh") + from hermes_cli.dep_ensure import _has_hermes_agent_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False), \ + patch("hermes_constants.get_hermes_home", return_value=tmp_path): + assert _has_hermes_agent_browser() is True + + +def test_ensure_dependency_uses_powershell_on_windows(tmp_path): + from hermes_cli.dep_ensure import ensure_dependency + scripts_dir = tmp_path / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "install.ps1").write_text("# fake") + with patch("hermes_cli.dep_ensure._IS_WINDOWS", True), \ + patch("hermes_cli.dep_ensure._DEP_CHECKS", {"node": lambda: False}), \ + patch("hermes_cli.dep_ensure._find_install_script", return_value=(scripts_dir / "install.ps1", "powershell")), \ + patch("hermes_cli.dep_ensure.shutil") as mock_shutil, \ + patch("hermes_constants.get_hermes_home", return_value=tmp_path / "fakehome"), \ + patch("subprocess.run") as mock_run, \ + patch("sys.stdin") as mock_stdin: + mock_shutil.which.side_effect = lambda name: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" if name == "powershell" else None + mock_stdin.isatty.return_value = False + mock_run.return_value = type("R", (), {"returncode": 0})() + ensure_dependency("node", interactive=False) + cmd = mock_run.call_args[0][0] + assert "powershell" in cmd[0].lower() + assert "-Ensure" in cmd + assert cmd[cmd.index("-Ensure") + 1] == "node" + assert "-HermesHome" in cmd + assert str(tmp_path / "fakehome") in cmd diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index 35dc7ace95..f9e05f99ba 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -679,6 +679,33 @@ def test_worker_log_rotation_keeps_one_generation(kanban_home, tmp_path): assert (log_dir / "t_aaaa.log.1").exists() +def test_worker_log_rotation_keeps_configured_generations(kanban_home): + log_dir = kanban_home / "kanban" / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + target = log_dir / "t_multi.log" + target.write_text("current") + (log_dir / "t_multi.log.1").write_text("one") + (log_dir / "t_multi.log.2").write_text("two") + + kb._rotate_worker_log(target, max_bytes=1, backup_count=3) + + assert not target.exists() + assert (log_dir / "t_multi.log.1").read_text() == "current" + assert (log_dir / "t_multi.log.2").read_text() == "one" + assert (log_dir / "t_multi.log.3").read_text() == "two" + + +def test_worker_log_rotation_config_defaults_and_overrides(): + assert kb.worker_log_rotation_config({}) == ( + kb.DEFAULT_LOG_ROTATE_BYTES, + kb.DEFAULT_LOG_BACKUP_COUNT, + ) + assert kb.worker_log_rotation_config({ + "worker_log_rotate_bytes": 10, + "worker_log_backup_count": 4, + }) == (10, 4) + + def test_read_worker_log_tail(kanban_home): log_dir = kanban_home / "kanban" / "logs" log_dir.mkdir(parents=True, exist_ok=True) @@ -2679,6 +2706,124 @@ def test_default_spawn_auto_loads_kanban_worker_skill(kanban_home, monkeypatch): assert env.get("HERMES_PROFILE") == "some-profile" +def test_default_spawn_raises_terminal_timeout_to_task_runtime(kanban_home, monkeypatch): + """A task runtime cap should raise the worker's terminal default. + + This is worker-scoped env only: normal CLI/gateway terminal settings stay + untouched, but long kanban tasks no longer inherit a short generic + TERMINAL_TIMEOUT that kills their foreground command first. + """ + captured = {} + + class FakeProc: + pid = 123 + + def fake_popen(cmd, **kwargs): + captured["env"] = kwargs.get("env", {}) + return FakeProc() + + monkeypatch.setattr("subprocess.Popen", fake_popen) + monkeypatch.setenv("TERMINAL_TIMEOUT", "180") + monkeypatch.delenv("TERMINAL_MAX_FOREGROUND_TIMEOUT", raising=False) + + conn = kb.connect() + try: + tid = kb.create_task( + conn, + title="long worker", + assignee="ops", + max_runtime_seconds=3600, + ) + task = kb.get_task(conn, tid) + workspace = kb.resolve_workspace(task) + kb._default_spawn(task, str(workspace)) + finally: + conn.close() + + assert captured["env"]["TERMINAL_TIMEOUT"] == "3570" + assert captured["env"]["TERMINAL_MAX_FOREGROUND_TIMEOUT"] == "3570" + assert os.environ["TERMINAL_TIMEOUT"] == "180" + + +def test_default_spawn_preserves_longer_terminal_timeout(kanban_home, monkeypatch): + """Kanban should never lower an explicitly larger terminal timeout.""" + captured = {} + + class FakeProc: + pid = 124 + + def fake_popen(cmd, **kwargs): + captured["env"] = kwargs.get("env", {}) + return FakeProc() + + monkeypatch.setattr("subprocess.Popen", fake_popen) + monkeypatch.setenv("TERMINAL_TIMEOUT", "7200") + monkeypatch.setenv("TERMINAL_MAX_FOREGROUND_TIMEOUT", "7200") + + conn = kb.connect() + try: + tid = kb.create_task( + conn, + title="already tuned", + assignee="ops", + max_runtime_seconds=3600, + ) + task = kb.get_task(conn, tid) + workspace = kb.resolve_workspace(task) + kb._default_spawn(task, str(workspace)) + finally: + conn.close() + + assert captured["env"]["TERMINAL_TIMEOUT"] == "7200" + assert captured["env"]["TERMINAL_MAX_FOREGROUND_TIMEOUT"] == "7200" + + +def test_default_spawn_leaves_terminal_timeout_without_runtime_cap(kanban_home, monkeypatch): + """Uncapped tasks keep the existing terminal timeout behavior.""" + captured = {} + + class FakeProc: + pid = 125 + + def fake_popen(cmd, **kwargs): + captured["env"] = kwargs.get("env", {}) + return FakeProc() + + monkeypatch.setattr("subprocess.Popen", fake_popen) + monkeypatch.setenv("TERMINAL_TIMEOUT", "180") + monkeypatch.delenv("TERMINAL_MAX_FOREGROUND_TIMEOUT", raising=False) + + conn = kb.connect() + try: + tid = kb.create_task(conn, title="uncapped", assignee="ops") + task = kb.get_task(conn, tid) + workspace = kb.resolve_workspace(task) + kb._default_spawn(task, str(workspace)) + finally: + conn.close() + + assert captured["env"]["TERMINAL_TIMEOUT"] == "180" + assert "TERMINAL_MAX_FOREGROUND_TIMEOUT" not in captured["env"] + + +def test_build_worker_context_includes_runtime_timeout_budget(kanban_home, monkeypatch): + monkeypatch.setenv("TERMINAL_TIMEOUT", "180") + conn = kb.connect() + try: + tid = kb.create_task( + conn, + title="long context", + assignee="ops", + max_runtime_seconds=3600, + ) + ctx = kb.build_worker_context(conn, tid) + finally: + conn.close() + + assert "Max runtime: 3600s" in ctx + assert "Terminal timeout: 3570s" in ctx + + # --------------------------------------------------------------------------- # Per-task force-loaded skills diff --git a/tests/hermes_cli/test_kanban_decompose_db.py b/tests/hermes_cli/test_kanban_decompose_db.py index 236fb1fff1..85026fd5a9 100644 --- a/tests/hermes_cli/test_kanban_decompose_db.py +++ b/tests/hermes_cli/test_kanban_decompose_db.py @@ -132,6 +132,22 @@ def test_decompose_rejects_out_of_range_parent(kanban_home): ) +def test_decompose_rejects_cyclic_parents(kanban_home): + with kb.connect() as conn: + tid = _create_triage(conn) + with pytest.raises(ValueError, match="cyclic dependency"): + kb.decompose_triage_task( + conn, + tid, + root_assignee="orch", + children=[ + {"title": "A", "parents": [1]}, + {"title": "B", "parents": [0]}, + ], + author="me", + ) + + def test_decompose_records_audit_comment_and_event(kanban_home): with kb.connect() as conn: tid = _create_triage(conn) diff --git a/tests/hermes_cli/test_kanban_diagnostics.py b/tests/hermes_cli/test_kanban_diagnostics.py index ad00e4136a..6329825ce1 100644 --- a/tests/hermes_cli/test_kanban_diagnostics.py +++ b/tests/hermes_cli/test_kanban_diagnostics.py @@ -177,10 +177,68 @@ def test_repeated_failures_escalates_to_critical(): def test_repeated_failures_below_threshold_silent(): - task = _task(consecutive_failures=2) + task = _task(consecutive_failures=1) assert kd.compute_task_diagnostics(task, [], []) == [] +def test_repeated_failures_default_matches_dispatcher_failure_limit(): + """Default dispatcher auto-blocks at 2 failures, so diagnostics must + also surface at 2 instead of waiting for the stale threshold of 3. + """ + task = _task(status="blocked", consecutive_failures=2, + last_failure_error="elapsed 600s > limit 300s") + runs = [_run(outcome="timed_out", run_id=1)] + diags = kd.compute_task_diagnostics(task, [], runs) + repeated = [d for d in diags if d.kind == "repeated_failures"] + assert len(repeated) == 1 + d = repeated[0] + assert d.data["failure_threshold"] == 2 + assert d.data["failure_limit"] == 2 + assert "default 5" not in d.detail + assert "configured for 2" in d.detail + + +def test_repeated_failures_derives_threshold_from_kanban_failure_limit(): + task = _task(status="ready", consecutive_failures=2, + last_failure_error="Profile 'debugger' does not exist") + runs = [_run(outcome="spawn_failed", run_id=1)] + assert kd.compute_task_diagnostics( + task, [], runs, config={"failure_limit": 4} + ) == [] + + task = _task(status="blocked", consecutive_failures=4, + last_failure_error="Profile 'debugger' does not exist") + diags = kd.compute_task_diagnostics( + task, [], runs, config={"failure_limit": 4} + ) + repeated = [d for d in diags if d.kind == "repeated_failures"] + assert len(repeated) == 1 + assert repeated[0].data["failure_threshold"] == 4 + assert repeated[0].data["failure_limit"] == 4 + + +def test_repeated_failures_explicit_threshold_overrides_failure_limit(): + task = _task(status="ready", consecutive_failures=3, + last_failure_error="Profile 'debugger' does not exist") + runs = [_run(outcome="spawn_failed", run_id=1)] + diags = kd.compute_task_diagnostics( + task, [], runs, config={"failure_limit": 5, "failure_threshold": 3} + ) + repeated = [d for d in diags if d.kind == "repeated_failures"] + assert len(repeated) == 1 + assert repeated[0].data["failure_threshold"] == 3 + assert repeated[0].data["failure_limit"] == 5 + + +def test_config_from_kanban_config_preserves_explicit_diagnostics_threshold(): + cfg = kd.config_from_kanban_config({ + "failure_limit": 5, + "diagnostics": {"failure_threshold": 3}, + }) + assert cfg["failure_threshold"] == 3 + assert cfg["failure_limit"] == 5 + + def test_repeated_crashes_counts_trailing_streak_only(): task = _task(status="ready", assignee="crashy") runs = [ @@ -555,3 +613,127 @@ def test_stranded_in_ready_works_on_real_db_row(kanban_home): assert stranded[0].data["assignee"] == "ghost" finally: conn.close() + + + +# --------------------------------------------------------------------------- +# triage_aux_unavailable rule — auto-decompose aware +# --------------------------------------------------------------------------- + + +def _triage_task(): + return _task(id="t_triage1", status="triage") + + +def test_triage_aux_unavailable_silent_without_config_context(): + """Low-level callers passing no config dict should not see this rule.""" + diags = kd.compute_task_diagnostics(_triage_task(), [], []) + assert [d for d in diags if d.kind == "triage_aux_unavailable"] == [] + + +def test_triage_aux_unavailable_silent_when_main_model_visible(): + """Default `provider: auto` falls back to the main model — no warning.""" + config = { + "auxiliary": {}, + "model": {"provider": "openrouter", "default": "qwen/qwen3"}, + "kanban": {"auto_decompose": True}, + } + diags = kd.compute_task_diagnostics(_triage_task(), [], [], config=config) + assert [d for d in diags if d.kind == "triage_aux_unavailable"] == [] + + +def test_triage_aux_unavailable_silent_when_decomposer_explicit(): + """User explicitly configured decomposer → no warning, even without main.""" + config = { + "auxiliary": { + "kanban_decomposer": {"provider": "openrouter", "model": "qwen/qwen3"}, + }, + "kanban": {"auto_decompose": True}, + } + diags = kd.compute_task_diagnostics(_triage_task(), [], [], config=config) + assert [d for d in diags if d.kind == "triage_aux_unavailable"] == [] + + +def test_triage_aux_unavailable_fires_auto_decompose_on_no_fallback(): + """auto_decompose=True, no decomposer, no main model → warn about decomposer.""" + config = { + "auxiliary": {}, + "kanban": {"auto_decompose": True}, + } + diags = kd.compute_task_diagnostics(_triage_task(), [], [], config=config) + triage = [d for d in diags if d.kind == "triage_aux_unavailable"] + assert len(triage) == 1 + d = triage[0] + assert d.severity == "warning" + assert "decomposer" in d.title.lower() + assert d.data["auto_decompose"] is True + assert d.data["primary_slot"] == "auxiliary.kanban_decomposer" + suggested = [a for a in d.actions if a.suggested] + assert suggested + assert "auxiliary.kanban_decomposer" in suggested[0].payload["command"] + + +def test_triage_aux_unavailable_fires_auto_decompose_off_points_at_specifier(): + """auto_decompose=False → primary is specifier, not decomposer.""" + config = { + "auxiliary": {}, + "kanban": {"auto_decompose": False}, + } + diags = kd.compute_task_diagnostics(_triage_task(), [], [], config=config) + triage = [d for d in diags if d.kind == "triage_aux_unavailable"] + assert len(triage) == 1 + d = triage[0] + assert "specifier" in d.title.lower() + assert d.data["auto_decompose"] is False + assert d.data["primary_slot"] == "auxiliary.triage_specifier" + # And it should offer the manual specify command as an action + labels = [a.label for a in d.actions] + assert any("hermes kanban specify" in l for l in labels) + + +def test_triage_aux_unavailable_skips_non_triage_tasks(): + config = {"auxiliary": {}, "kanban": {"auto_decompose": True}} + task = _task(status="todo") + diags = kd.compute_task_diagnostics(task, [], [], config=config) + assert [d for d in diags if d.kind == "triage_aux_unavailable"] == [] + + +def test_triage_aux_status_recognises_auto_default_as_not_explicit(): + """Default `provider: auto` with empty fields → not 'explicit'.""" + status = kd.triage_aux_status({ + "auxiliary": { + "kanban_decomposer": {"provider": "auto", "model": ""}, + }, + "kanban": {}, + }) + assert status is not None + assert status["decomposer_explicit"] is False + + +def test_triage_aux_status_recognises_explicit_model_only(): + """Even with provider=auto, a non-empty model counts as explicit.""" + status = kd.triage_aux_status({ + "auxiliary": { + "kanban_decomposer": {"provider": "auto", "model": "qwen/qwen3"}, + }, + "kanban": {}, + }) + assert status is not None + assert status["decomposer_explicit"] is True + + +def test_config_from_runtime_config_carries_aux_and_model(): + cfg = kd.config_from_runtime_config({ + "kanban": {"failure_limit": 5, "auto_decompose": False}, + "auxiliary": {"kanban_decomposer": {"provider": "openrouter"}}, + "model": {"provider": "openrouter", "default": "qwen/qwen3"}, + }) + assert cfg["failure_threshold"] == 5 + assert cfg["kanban"]["auto_decompose"] is False + assert cfg["auxiliary"]["kanban_decomposer"]["provider"] == "openrouter" + assert cfg["model"]["default"] == "qwen/qwen3" + + +def test_config_from_runtime_config_handles_empty_input(): + assert kd.config_from_runtime_config(None) == {} + assert kd.config_from_runtime_config({}) == {} diff --git a/tests/hermes_cli/test_kanban_notify.py b/tests/hermes_cli/test_kanban_notify.py index ddfa4b40aa..1ebf92705d 100644 --- a/tests/hermes_cli/test_kanban_notify.py +++ b/tests/hermes_cli/test_kanban_notify.py @@ -479,3 +479,162 @@ async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home): assert kb.list_notify_subs(conn) == [] finally: conn.close() + + +@pytest.mark.asyncio +async def test_notifier_uploads_artifacts_on_completion(kanban_home, tmp_path): + """When a completed event carries ``artifacts`` in its payload, the + notifier uploads each file to the subscribed chat as a native + attachment. Images batch through send_multiple_images; documents + route through send_document. See the artifacts wiring in + gateway/run.py._deliver_kanban_artifacts. + """ + import hermes_cli.kanban_db as kb + from gateway.run import GatewayRunner + from gateway.config import Platform + from tools import kanban_tools as kt + + # Materialize real files so os.path.isfile passes inside the helper. + chart_path = tmp_path / "q3-revenue.png" + chart_path.write_bytes(b"PNG-fake-bytes") + report_path = tmp_path / "report.pdf" + report_path.write_bytes(b"%PDF-fake") + + conn = kb.connect() + try: + tid = kb.create_task(conn, title="render q3 chart", assignee="worker1") + kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") + finally: + conn.close() + + # Use the production handler so we exercise the full path: tool args + # → metadata.artifacts → event payload promotion. + import os + os.environ["HERMES_KANBAN_TASK"] = tid + try: + out = kt._handle_complete({ + "summary": "rendered the chart", + "artifacts": [str(chart_path), str(report_path)], + }) + finally: + os.environ.pop("HERMES_KANBAN_TASK", None) + import json as _json + assert _json.loads(out)["ok"] is True + + runner = object.__new__(GatewayRunner) + runner._running = True + runner._kanban_sub_fail_counts = {} + + fake_adapter = MagicMock() + fake_adapter.name = "telegram" + + sends: list = [] + images_uploaded: list = [] + documents_uploaded: list = [] + + async def _send(chat_id, msg, metadata=None): + sends.append((chat_id, msg)) + runner._running = False + + async def _send_images(chat_id, images, metadata=None, **_kw): + images_uploaded.extend(p for p, _ in images) + + async def _send_document(chat_id, file_path, metadata=None, **_kw): + documents_uploaded.append(file_path) + + fake_adapter.send = AsyncMock(side_effect=_send) + fake_adapter.send_multiple_images = AsyncMock(side_effect=_send_images) + fake_adapter.send_document = AsyncMock(side_effect=_send_document) + # extract_local_files is used internally for legacy path fallback; + # the real BasePlatformAdapter implementation lives there, so wire it. + from gateway.platforms.base import BasePlatformAdapter + fake_adapter.extract_local_files = BasePlatformAdapter.extract_local_files + + runner.adapters = {Platform.TELEGRAM: fake_adapter} + + _orig_sleep = asyncio.sleep + + async def _fast_sleep(_): + await _orig_sleep(0) + + with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep): + await asyncio.wait_for( + runner._kanban_notifier_watcher(interval=1), + timeout=10.0, + ) + + # The text completion notification fired. + assert len(sends) == 1 + # The PNG rode the image-batch path. + assert any("q3-revenue.png" in p for p in images_uploaded), images_uploaded + # The PDF rode the document path. + assert any("report.pdf" in p for p in documents_uploaded), documents_uploaded + + +@pytest.mark.asyncio +async def test_notifier_artifact_delivery_skips_missing_files(kanban_home, tmp_path): + """Missing artifact paths are silently skipped — they may have been + referenced by name only. The notifier must not crash and must still + deliver any artifacts that do exist.""" + import hermes_cli.kanban_db as kb + from gateway.run import GatewayRunner + from gateway.config import Platform + from tools import kanban_tools as kt + + real_pdf = tmp_path / "real.pdf" + real_pdf.write_bytes(b"%PDF-fake") + + conn = kb.connect() + try: + tid = kb.create_task(conn, title="t", assignee="worker1") + kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") + finally: + conn.close() + + import os + os.environ["HERMES_KANBAN_TASK"] = tid + try: + kt._handle_complete({ + "summary": "one real, one ghost", + "artifacts": [str(real_pdf), "/tmp/definitely-does-not-exist.pdf"], + }) + finally: + os.environ.pop("HERMES_KANBAN_TASK", None) + + runner = object.__new__(GatewayRunner) + runner._running = True + runner._kanban_sub_fail_counts = {} + + fake_adapter = MagicMock() + fake_adapter.name = "telegram" + + documents_uploaded: list = [] + + async def _send(chat_id, msg, metadata=None): + runner._running = False + + async def _send_document(chat_id, file_path, metadata=None, **_kw): + documents_uploaded.append(file_path) + + fake_adapter.send = AsyncMock(side_effect=_send) + fake_adapter.send_document = AsyncMock(side_effect=_send_document) + fake_adapter.send_multiple_images = AsyncMock() + from gateway.platforms.base import BasePlatformAdapter + fake_adapter.extract_local_files = BasePlatformAdapter.extract_local_files + + runner.adapters = {Platform.TELEGRAM: fake_adapter} + + _orig_sleep = asyncio.sleep + + async def _fast_sleep(_): + await _orig_sleep(0) + + with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep): + await asyncio.wait_for( + runner._kanban_notifier_watcher(interval=1), + timeout=10.0, + ) + + # Only the real file was uploaded. + assert len(documents_uploaded) == 1 + assert "real.pdf" in documents_uploaded[0] diff --git a/tests/hermes_cli/test_pip_install_detection.py b/tests/hermes_cli/test_pip_install_detection.py index b0f4cbd75a..da3dd35e32 100644 --- a/tests/hermes_cli/test_pip_install_detection.py +++ b/tests/hermes_cli/test_pip_install_detection.py @@ -4,7 +4,8 @@ from unittest.mock import patch def test_pip_install_detected_when_no_git_dir(tmp_path): """When PROJECT_ROOT has no .git, detect as pip install.""" - with patch("hermes_cli.config.get_managed_system", return_value=None): + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): from hermes_cli.config import detect_install_method method = detect_install_method(project_root=tmp_path) assert method == "pip" @@ -13,7 +14,8 @@ def test_pip_install_detected_when_no_git_dir(tmp_path): def test_git_install_detected_when_git_dir_exists(tmp_path): """When PROJECT_ROOT has .git, detect as git install.""" (tmp_path / ".git").mkdir() - with patch("hermes_cli.config.get_managed_system", return_value=None): + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): from hermes_cli.config import detect_install_method method = detect_install_method(project_root=tmp_path) assert method == "git" @@ -22,7 +24,8 @@ def test_git_install_detected_when_git_dir_exists(tmp_path): def test_managed_install_takes_precedence(tmp_path): """When HERMES_MANAGED is set, that takes precedence over git detection.""" (tmp_path / ".git").mkdir() - with patch("hermes_cli.config.get_managed_system", return_value="NixOS"): + with patch("hermes_cli.config.get_managed_system", return_value="NixOS"), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): from hermes_cli.config import detect_install_method method = detect_install_method(project_root=tmp_path) assert method == "nixos" @@ -35,3 +38,25 @@ def test_recommended_update_command_pip(): assert "pip install" in cmd or "uv pip install" in cmd assert "--upgrade" in cmd assert "hermes-agent" in cmd + + +def test_stamp_file_takes_precedence(tmp_path): + (tmp_path / ".git").mkdir() + (tmp_path / ".install_method").write_text("docker\n") + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): + from hermes_cli.config import detect_install_method + assert detect_install_method(project_root=tmp_path) == "docker" + + +def test_docker_detected_via_dockerenv(tmp_path): + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path), \ + patch("hermes_constants.is_container", return_value=True): + from hermes_cli.config import detect_install_method + assert detect_install_method(project_root=tmp_path) == "docker" + + +def test_recommended_update_command_docker(): + from hermes_cli.config import recommended_update_command_for_method + assert "docker pull" in recommended_update_command_for_method("docker") diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index c31ae6f08b..1dbd72ad93 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -318,6 +318,93 @@ def test_complete_with_result_only(worker_env): assert d["ok"] is True +def test_complete_with_artifacts_lands_in_event_payload(worker_env): + """``artifacts=[...]`` rides into the completed event payload so the + gateway notifier can upload them as native attachments. See the + kanban notifier in gateway/run.py for the consumer side.""" + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + out = kt._handle_complete({ + "summary": "rendered the chart", + "artifacts": ["/tmp/q3-revenue.png", "/tmp/q3-report.pdf"], + }) + assert json.loads(out)["ok"] is True + + conn = kb.connect() + try: + events = kb.list_events(conn, worker_env) + # Find the completion event + completed = [e for e in events if e.kind == "completed"] + assert len(completed) == 1 + payload = completed[0].payload or {} + assert payload.get("artifacts") == [ + "/tmp/q3-revenue.png", + "/tmp/q3-report.pdf", + ] + # And the artifacts also live on metadata for downstream workers + run = kb.latest_run(conn, worker_env) + assert run.metadata.get("artifacts") == [ + "/tmp/q3-revenue.png", + "/tmp/q3-report.pdf", + ] + finally: + conn.close() + + +def test_complete_artifacts_accepts_single_string(worker_env): + """A bare string is auto-promoted to a single-element list for convenience.""" + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + out = kt._handle_complete({ + "summary": "one chart", + "artifacts": "/tmp/chart.png", + }) + assert json.loads(out)["ok"] is True + + conn = kb.connect() + try: + run = kb.latest_run(conn, worker_env) + assert run.metadata.get("artifacts") == ["/tmp/chart.png"] + finally: + conn.close() + + +def test_complete_artifacts_merges_with_explicit_metadata_field(worker_env): + """If the worker passes metadata.artifacts AND the top-level artifacts + param, merge the two without duplicates.""" + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + out = kt._handle_complete({ + "summary": "merged", + "metadata": {"artifacts": ["/tmp/a.png"], "other": "fact"}, + "artifacts": ["/tmp/b.pdf", "/tmp/a.png"], + }) + assert json.loads(out)["ok"] is True + + conn = kb.connect() + try: + run = kb.latest_run(conn, worker_env) + # Order: existing entries first, then new ones, deduplicated. + assert run.metadata.get("artifacts") == ["/tmp/a.png", "/tmp/b.pdf"] + assert run.metadata.get("other") == "fact" + finally: + conn.close() + + +def test_complete_rejects_non_list_artifacts(worker_env): + """Non-list, non-string artifacts should be rejected with a clear error.""" + from tools import kanban_tools as kt + out = kt._handle_complete({ + "summary": "bad shape", + "artifacts": {"not": "a list"}, + }) + err = json.loads(out).get("error", "") + assert "artifacts must be a list" in err + + def test_complete_rejects_no_handoff(worker_env): from tools import kanban_tools as kt out = kt._handle_complete({}) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index fb96649cb3..447f650071 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -158,8 +158,9 @@ def _browser_candidate_path_dirs() -> list[str]: """Return ordered browser CLI PATH candidates shared by discovery and execution.""" hermes_home = get_hermes_home() hermes_node_bin = str(hermes_home / "node" / "bin") + hermes_node_root = str(hermes_home / "node") hermes_nm_bin = str(hermes_home / "node_modules" / ".bin") - return [hermes_node_bin, hermes_nm_bin, *list(_discover_homebrew_node_dirs()), *_SANE_PATH_DIRS] + return [hermes_node_bin, hermes_node_root, hermes_nm_bin, *list(_discover_homebrew_node_dirs()), *_SANE_PATH_DIRS] def _merge_browser_path(existing_path: str = "") -> str: @@ -1827,6 +1828,12 @@ def _find_agent_browser() -> str: if not recheck: hermes_nm = str(get_hermes_home() / "node_modules" / ".bin") recheck = shutil.which("agent-browser", path=hermes_nm) + if not recheck: + hermes_node_bin = str(get_hermes_home() / "node" / "bin") + recheck = shutil.which("agent-browser", path=hermes_node_bin) + if not recheck: + hermes_node_root = str(get_hermes_home() / "node") + recheck = shutil.which("agent-browser", path=hermes_node_root) if recheck: _cached_agent_browser = recheck _agent_browser_resolved = True diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index fab0a68c92..eaf32a3a37 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -371,6 +371,7 @@ def _handle_complete(args: dict, **kw) -> str: metadata = args.get("metadata") result = args.get("result") created_cards = args.get("created_cards") + artifacts = args.get("artifacts") if created_cards is not None: if isinstance(created_cards, str): # Accept a single id as a string for convenience. @@ -384,6 +385,45 @@ def _handle_complete(args: dict, **kw) -> str: created_cards = [ str(c).strip() for c in created_cards if str(c).strip() ] + if artifacts is not None: + if isinstance(artifacts, str): + # Accept a single path as a string for convenience. + artifacts = [artifacts] + if not isinstance(artifacts, (list, tuple)): + return tool_error( + f"artifacts must be a list of file paths, got " + f"{type(artifacts).__name__}" + ) + artifacts = [ + str(p).strip() for p in artifacts if str(p).strip() + ] + # Carry the artifact list inside metadata so it rides the + # existing completed-event payload without a schema change at + # the DB layer. The gateway notifier reads payload['artifacts'] + # off the completion event and uploads each path as a native + # attachment. + if artifacts: + if metadata is None: + metadata = {} + elif not isinstance(metadata, dict): + return tool_error( + f"metadata must be an object/dict, got " + f"{type(metadata).__name__}" + ) + # Don't overwrite an existing metadata.artifacts the worker + # passed manually — merge instead. + existing = metadata.get("artifacts") + if isinstance(existing, (list, tuple)): + merged: list[str] = [] + seen: set[str] = set() + for item in list(existing) + artifacts: + s = str(item).strip() + if s and s not in seen: + seen.add(s) + merged.append(s) + metadata["artifacts"] = merged + else: + metadata["artifacts"] = artifacts if not (summary or result): return tool_error( "provide at least one of: summary (preferred), result" @@ -760,7 +800,12 @@ KANBAN_COMPLETE_SCHEMA = { "tasks via ``kanban_create`` during this run, list their ids " "in ``created_cards`` — the kernel verifies them so phantom " "references are caught before they leak into downstream " - "automation." + "automation. If you produced deliverable files (charts, PDFs, " + "spreadsheets, generated images), list their absolute paths " + "in ``artifacts`` — the gateway notifier will upload them as " + "native attachments to the human who subscribed to the task, " + "so the deliverable lands in their chat alongside the summary " + "instead of being a path they have to fetch by hand." ), "parameters": { "type": "object", @@ -811,6 +856,25 @@ KANBAN_COMPLETE_SCHEMA = { "did not create any cards." ), }, + "artifacts": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Optional list of absolute paths to deliverable " + "files you produced during this run — generated " + "charts, PDFs, spreadsheets, images, archives. " + "Examples: [\"/tmp/q3-revenue.png\", " + "\"/tmp/report.pdf\"]. The gateway notifier " + "uploads each path as a native attachment to the " + "subscribed chat (images embed inline, everything " + "else uploads as a file) so the deliverable " + "lands with the completion notification. Skip " + "intermediate scratch files and references that " + "are not the deliverable. The path must exist " + "on disk when the notifier runs; missing files " + "are silently skipped." + ), + }, }, "required": [], }, diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index cd278eecdf..0c7ec3b06a 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -4,7 +4,7 @@ import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js' import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' import { turnController } from '../app/turnController.js' import { getTurnState, resetTurnState } from '../app/turnStore.js' -import { patchUiState, resetUiState } from '../app/uiStore.js' +import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' import { estimateTokensRough } from '../lib/text.js' import type { Msg } from '../types.js' @@ -132,6 +132,46 @@ describe('createGatewayEventHandler', () => { expect(ctx.system.sys).toHaveBeenCalledWith('compressing 968 messages (~123,400 tok)…') }) + it('keeps goal verdict text in transcript but shows a brief idle status (#goal statusbar)', () => { + const appended: Msg[] = [] + const ctx = buildCtx(appended) + const onEvent = createGatewayEventHandler(ctx) + const verdict = '✓ Goal achieved: long judge reason goes only in transcript, not merged with cwd label.' + + vi.useFakeTimers() + try { + onEvent({ + payload: { kind: 'goal', text: verdict }, + type: 'status.update' + } as any) + + expect(ctx.system.sys).toHaveBeenCalledWith(verdict) + expect(getUiState().status).toBe('✓ goal complete') + + vi.advanceTimersByTime(6001) + expect(getUiState().status).toBe('ready') + } finally { + vi.useRealTimers() + } + }) + + it('maps goal status.update prefixes to short status strings', () => { + const ctx = buildCtx([]) + const onEvent = createGatewayEventHandler(ctx) + + onEvent({ + payload: { kind: 'goal', text: '↻ Continuing toward goal (1/10): reason' }, + type: 'status.update' + } as any) + expect(getUiState().status).toBe('↻ goal continuing') + + onEvent({ + payload: { kind: 'goal', text: '⏸ Goal paused — budget exhausted.' }, + type: 'status.update' + } as any) + expect(getUiState().status).toBe('⏸ goal paused') + }) + it('surfaces self-improvement review summaries as a persistent system line', () => { const appended: Msg[] = [] const ctx = buildCtx(appended) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index ca269a131b..267334bfd7 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -338,14 +338,23 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } - setStatus(p.text) - - if (p.kind === 'compressing') { + if (p.kind === 'goal') { sys(p.text) + const brief = p.text.startsWith('✓') + ? '✓ goal complete' + : p.text.startsWith('↻') + ? '↻ goal continuing' + : p.text.startsWith('⏸') + ? '⏸ goal paused' + : 'ready' + setStatus(brief) + restoreStatusAfter(6000) return } - if (p.kind === 'goal') { + setStatus(p.text) + + if (p.kind === 'compressing') { sys(p.text) return } diff --git a/uv.lock b/uv.lock index eca6288030..e7641abd22 100644 --- a/uv.lock +++ b/uv.lock @@ -40,7 +40,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.4" +version = "3.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -51,93 +51,93 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" }, - { url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" }, - { url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" }, - { url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" }, - { url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" }, - { url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" }, - { url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" }, - { url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" }, - { url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" }, - { url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" }, - { url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" }, - { url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" }, - { url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" }, - { url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" }, - { url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" }, - { url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" }, - { url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" }, - { url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" }, - { url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" }, - { url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" }, - { url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" }, - { url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" }, - { url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" }, - { url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" }, - { url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" }, - { url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" }, - { url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" }, - { url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" }, - { url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" }, - { url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" }, - { url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" }, - { url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" }, - { url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" }, - { url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" }, - { url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" }, - { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, - { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, - { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, - { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, - { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, - { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, - { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, - { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, - { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, - { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, - { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, - { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, - { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, - { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, - { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, - { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, - { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, - { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, - { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, - { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, - { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, - { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, - { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, - { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, - { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, ] [[package]] @@ -321,7 +321,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.87.0" +version = "0.86.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -333,9 +333,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/8f/3281edf7c35cbac169810e5388eb9b38678c7ea9867c2d331237bd5dff08/anthropic-0.87.0.tar.gz", hash = "sha256:098fef3753cdd3c0daa86f95efb9c8d03a798d45c5170329525bb4653f6702d0", size = 588982, upload-time = "2026-03-31T17:52:41.697Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5", size = 583820, upload-time = "2026-03-18T18:43:08.017Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/02/99bf351933bdea0545a2b6e2d812ed878899e9a95f618351dfa3d0de0e69/anthropic-0.87.0-py3-none-any.whl", hash = "sha256:e2669b86d42c739d3df163f873c51719552e263a3d85179297180fb4fa00a236", size = 472126, upload-time = "2026-03-31T17:52:40.174Z" }, + { url = "https://files.pythonhosted.org/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400, upload-time = "2026-03-18T18:43:06.526Z" }, ] [[package]] @@ -1569,11 +1569,10 @@ wheels = [ [[package]] name = "hermes-agent" -version = "0.13.0" +version = "0.14.0" source = { editable = "." } dependencies = [ { name = "croniter" }, - { name = "cryptography" }, { name = "fire" }, { name = "httpx", extra = ["socks"] }, { name = "jinja2" }, @@ -1759,19 +1758,18 @@ youtube = [ [package.metadata] requires-dist = [ { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" }, - { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.13.4" }, - { name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.4" }, - { name = "aiohttp", marker = "extra == 'slack'", specifier = "==3.13.4" }, - { name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.4" }, + { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.13.3" }, + { name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.3" }, + { name = "aiohttp", marker = "extra == 'slack'", specifier = "==3.13.3" }, + { name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.3" }, { name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = "==0.11.0" }, { name = "aiosqlite", marker = "extra == 'matrix'", specifier = "==0.22.1" }, { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = "==2.2.42" }, - { name = "anthropic", marker = "extra == 'anthropic'", specifier = "==0.87.0" }, + { name = "anthropic", marker = "extra == 'anthropic'", specifier = "==0.86.0" }, { name = "asyncpg", marker = "extra == 'matrix'", specifier = "==0.31.0" }, { name = "boto3", marker = "extra == 'bedrock'", specifier = "==1.42.89" }, { name = "brotlicffi", marker = "extra == 'messaging'", specifier = "==1.2.0.1" }, { name = "croniter", specifier = "==6.0.0" }, - { name = "cryptography", specifier = "==46.0.7" }, { name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" }, { name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" }, { name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = "==0.24.3" }, diff --git a/website/docs/developer-guide/agent-loop.md b/website/docs/developer-guide/agent-loop.md index cf9cb1c1ef..fdc0cc3c8f 100644 --- a/website/docs/developer-guide/agent-loop.md +++ b/website/docs/developer-guide/agent-loop.md @@ -194,7 +194,7 @@ When the primary model fails (429 rate limit, 5xx server error, 401/403 auth err 3. On success, continue the conversation with the new provider 4. On 401/403, attempt credential refresh before failing over -The fallback system also covers auxiliary tasks independently — vision, compression, web extraction, and session search each have their own fallback chain configurable via the `auxiliary.*` config section. +The fallback system also covers auxiliary tasks independently — vision, compression, and web extraction each have their own fallback chain configurable via the `auxiliary.*` config section. ## Compression and Persistence diff --git a/website/docs/developer-guide/provider-runtime.md b/website/docs/developer-guide/provider-runtime.md index 830382479f..67c86b01c2 100644 --- a/website/docs/developer-guide/provider-runtime.md +++ b/website/docs/developer-guide/provider-runtime.md @@ -150,7 +150,6 @@ Auxiliary tasks such as: - vision - web extraction summarization - context compression summaries -- session search summarization - skills hub operations - MCP helper operations - memory flushes diff --git a/website/docs/user-guide/configuring-models.md b/website/docs/user-guide/configuring-models.md index 4c12fa7e7d..a4ce79eea3 100644 --- a/website/docs/user-guide/configuring-models.md +++ b/website/docs/user-guide/configuring-models.md @@ -7,7 +7,7 @@ sidebar_position: 3 Hermes uses two kinds of model slots: - **Main model** — what the agent thinks with. Every user message, every tool-call loop, every streamed response goes through this model. -- **Auxiliary models** — smaller side-jobs the agent offloads. Context compression, vision (image analysis), web-page summarization, session search, approval scoring, MCP tool routing, session-title generation, and skill search. Each has its own slot and can be overridden independently. +- **Auxiliary models** — smaller side-jobs the agent offloads. Context compression, vision (image analysis), web-page summarization, approval scoring, MCP tool routing, session-title generation, and skill search. Each has its own slot and can be overridden independently. This page covers configuring both from the dashboard. If you prefer config files or the CLI, jump to [Alternative methods](#alternative-methods) at the bottom. @@ -52,7 +52,6 @@ Every auxiliary task defaults to `auto` — meaning Hermes uses your main model | **Title Gen** | Almost always. A $0.10/M flash model writes session titles as well as Opus. Default config sets this to `google/gemini-3-flash-preview` on OpenRouter. | | **Vision** | When your main model is a coding model without vision (e.g. Kimi, DeepSeek). Point it at `google/gemini-2.5-flash` or `gpt-4o-mini`. | | **Compression** | When you're burning reasoning tokens on Opus/M2.7 just to summarize context. A fast chat model does the job at 1/50th the cost. | -| **Session Search** | When recall queries fan out — default max_concurrency is 3. A cheap model keeps the bill predictable. | | **Approval** | For `approval_mode: smart` — a fast/cheap model (haiku, flash, gpt-5-mini) decides whether to auto-approve low-risk commands. Expensive models here are waste. | | **Web Extract** | When you use `web_extract` heavily. Same logic as compression — summarization doesn't need reasoning. | | **Skills Hub** | `hermes skills search` uses this. Usually fine at `auto`. | diff --git a/website/docs/user-guide/features/codex-app-server-runtime.md b/website/docs/user-guide/features/codex-app-server-runtime.md index 575250d9b0..130e790f06 100644 --- a/website/docs/user-guide/features/codex-app-server-runtime.md +++ b/website/docs/user-guide/features/codex-app-server-runtime.md @@ -242,7 +242,7 @@ default_permissions = ":read-only" ## Auxiliary tasks and ChatGPT subscription token cost -When this runtime is on with the `openai-codex` provider, **auxiliary tasks (title generation, context compression, vision auto-detect, session search summarization, the background self-improvement review fork) also flow through your ChatGPT subscription by default**, because Hermes' auxiliary client uses the main provider/model when no per-task override is set. +When this runtime is on with the `openai-codex` provider, **auxiliary tasks (title generation, context compression, vision auto-detect, the background self-improvement review fork) also flow through your ChatGPT subscription by default**, because Hermes' auxiliary client uses the main provider/model when no per-task override is set. This isn't specific to `codex_app_server` — it's true for the existing `codex_responses` path too — but it's more visible here because you're explicitly opting in for the subscription billing. @@ -259,9 +259,6 @@ auxiliary: vision_detect: provider: openrouter model: google/gemini-3-flash-preview - session_search: - provider: openrouter - model: google/gemini-3-flash-preview goal_judge: provider: openrouter model: google/gemini-3-flash-preview diff --git a/website/docs/user-guide/features/deliverable-mode.md b/website/docs/user-guide/features/deliverable-mode.md new file mode 100644 index 0000000000..e08e3966fa --- /dev/null +++ b/website/docs/user-guide/features/deliverable-mode.md @@ -0,0 +1,130 @@ +--- +title: Deliverable Mode (Artifacts in Chat) +sidebar_label: Deliverable Mode +description: How the agent ships generated charts, PDFs, spreadsheets, and other files as native attachments in messaging platforms. +--- + +# Deliverable Mode + +When Hermes Agent runs inside a messaging gateway (Slack, Discord, Telegram, +WhatsApp, Signal, etc.), it can deliver generated files directly into the +chat — not as paths the user has to copy, but as native attachments. + +A chart shows up as an inline image. A PDF report shows up as a file +download. A spreadsheet uploads as `.xlsx`. The agent does not need to +write a `MEDIA:` tag or do anything special — it just generates the file +and mentions its absolute path in the response. The gateway picks the path +out of the text, removes it from the visible message, and uploads the +file natively. + +## How it works + +Three pieces fit together: + +1. **The agent has tools that produce files.** `execute_code` for charts via + matplotlib, the `latex-pdf-report` skill for PDFs, the `powerpoint` skill + for decks, `image_generate` for images, `text_to_speech` for audio, and so + on. + +2. **The gateway scans agent responses for file paths.** Any absolute path + (`/tmp/...`) or home-relative path (`~/...`) ending in a supported + extension gets extracted. Paths inside code blocks and inline code are + ignored so code samples are never mutilated. + +3. **The gateway dispatches by file type.** Images embed inline where the + platform supports it; videos embed inline; audio routes to voice/audio + attachments; everything else uploads as a file attachment. + +## Supported file extensions + +| Category | Extensions | Delivery | +|---|---|---| +| Images | `.png .jpg .jpeg .gif .webp .bmp .tiff .svg` | Inline embed | +| Video | `.mp4 .mov .avi .mkv .webm` | Inline embed (where supported) | +| Audio | `.mp3 .wav .ogg .m4a .flac` | Voice / audio attachment | +| Documents | `.pdf .docx .doc .odt .rtf .txt .md` | File upload | +| Data | `.xlsx .xls .csv .tsv .json .xml .yaml .yml` | File upload | +| Presentations | `.pptx .ppt .odp` | File upload | +| Archives | `.zip .tar .gz .tgz .bz2 .7z` | File upload | +| Web | `.html .htm` | File upload | + +`.py`, `.log`, and other source-file extensions are intentionally excluded so +the agent doesn't auto-ship arbitrary source files; if you want to send code +to the user, use a code block. + +## Encouraging the agent to produce artifacts + +The agent doesn't reach for artifacts by default — it has to know to. +Two ways to nudge it: + +**Per-session:** ask explicitly ("send me the comparison as a chart", +"return the data as a CSV") or write your own custom-instructions / +personality entry that biases toward artifact-style replies on +messaging platforms. + +**Project-level:** add the bias to `AGENTS.md` / `CLAUDE.md` / +`.cursorrules` in a project the agent works from, or to your global +custom instructions in `~/.hermes/config.yaml` under `agent.custom_instructions`. + +The mechanic the agent has to use is simple: render the file to an +absolute path (e.g. `/tmp/q3-revenue.png`) and mention that path as +plain text in the reply. The gateway does the rest. Paths inside +fenced code blocks or backticks are ignored so code samples are never +mutilated. + +## Kanban: artifacts ride completion notifications + +If you use Hermes' kanban multi-agent workflow, workers can attach +deliverable files to their `kanban_complete` call: + +```python +kanban_complete( + summary="rendered Q3 revenue chart and report", + artifacts=[ + "/tmp/q3-revenue.png", + "/tmp/q3-report.pdf", + ], +) +``` + +When the gateway notifier delivers the "task completed" message to whoever +subscribed to the task in Slack/Telegram/etc., it also uploads each artifact +as a native attachment to that chat. The human gets the deliverable and the +summary in one place. + +Files that don't exist on disk when the notifier runs are silently skipped. + +## Connecting more services with MCP + +Beyond the artifact-delivery pipeline, the agent can reach into other +services via MCP (Model Context Protocol). The MCP ecosystem ships +community servers for most popular tools — install whichever you need: + +| Service | What it unlocks | +|---|---| +| **Notion** | Read/write Notion pages, databases, query workspace | +| **GitHub** | Issues, PRs, comments, repo search beyond the gh CLI | +| **Linear** | Tickets, projects, cycles | +| **Slack** | Workspace-wide search, read other channels | +| **Gmail** | Inbox triage, send mail, label management | +| **Salesforce** | Leads, opportunities, account data | +| **Snowflake / BigQuery** | SQL against data warehouses | +| **Google Drive** | File search, contents, share management | + +Install MCP servers via `~/.hermes/config.yaml` under the `mcp_servers` +section. See [MCP integration](./mcp.md) for the full setup guide. + +## Comparison to Perplexity Computer in Slack + +Perplexity Computer's Slack integration is built around the same idea: +the agent generates a deliverable (chart, PDF, slide deck) and posts it +back into the thread as a native attachment. Hermes Agent's deliverable +mode provides the same user-facing pattern locally: + +- Generation happens in the user's own venv / sandbox (no remote tenant). +- Files land in the chat via the same Slack `files.uploadV2` API. +- Connector breadth comes via MCP rather than a curated catalog of 400 + hosted integrations — install the ones you actually use. + +OAuth tokens stay on the user's machine in `auth.json` / `.env`. No hosted +token storage. No multi-tenant microVM. Same end result. diff --git a/website/docs/user-guide/features/memory.md b/website/docs/user-guide/features/memory.md index 77f74d28a8..5c07df6357 100644 --- a/website/docs/user-guide/features/memory.md +++ b/website/docs/user-guide/features/memory.md @@ -177,19 +177,23 @@ Memory entries are scanned for injection and exfiltration patterns before being Beyond MEMORY.md and USER.md, the agent can search its past conversations using the `session_search` tool: - All CLI and messaging sessions are stored in SQLite (`~/.hermes/state.db`) with FTS5 full-text search -- Search queries return relevant past conversations with Gemini Flash summarization +- Search queries return actual messages from the DB — no LLM summarization, no truncation - The agent can find things it discussed weeks ago, even if they're not in its active memory +- The agent can also scroll forward/backward inside any session it finds ```bash hermes sessions list # Browse past sessions ``` +See [Session Search Tool](/docs/user-guide/sessions#session-search-tool) for the three calling shapes (discovery / scroll / browse) and the response format. + ### session_search vs memory | Feature | Persistent Memory | Session Search | |---------|------------------|----------------| | **Capacity** | ~1,300 tokens total | Unlimited (all sessions) | -| **Speed** | Instant (in system prompt) | Requires search + LLM summarization | +| **Speed** | Instant (in system prompt) | ~20ms FTS5 query, ~1ms scroll | +| **Cost** | Token cost in every prompt | Free — no LLM calls | | **Use case** | Key facts always available | Finding specific past conversations | | **Management** | Manually curated by agent | Automatic — all sessions stored | | **Token cost** | Fixed per session (~1,300 tokens) | On-demand (searched when needed) | diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index e90c3f60bc..2a663bf5ac 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -366,25 +366,66 @@ For deeper analytics — token usage, cost estimates, tool breakdown, and activi ## Session Search Tool -The agent has a built-in `session_search` tool that performs full-text search across all past conversations using SQLite's FTS5 engine. +The agent has a built-in `session_search` tool that performs full-text search across all past conversations using SQLite's FTS5 engine — and lets the agent scroll through any session it finds. No LLM calls, no summarization, no truncation. Every shape returns actual messages from the DB. -### How It Works +### Three calling shapes -1. FTS5 searches matching messages ranked by relevance -2. Groups results by session, takes the top N unique sessions (default 3) -3. Loads each session's conversation, truncates to ~100K chars centered on matches -4. Sends to a fast summarization model for focused summaries -5. Returns per-session summaries with metadata and surrounding context +The tool infers what you want from which arguments you set. There's no `mode` parameter. + +**1. Discovery — pass `query`:** + +```python +session_search(query="auth refactor", limit=3) +``` + +Runs FTS5, dedupes hits by session lineage, returns the top N sessions. Each result carries: + +- `session_id`, `title`, `when`, `source` +- `snippet` — FTS5-highlighted match excerpt +- `bookend_start` — first 3 user+assistant messages of the session (the goal/kickoff) +- `messages` — ±5 messages around the FTS5 match, with the anchor message flagged (the hit in context) +- `bookend_end` — last 3 user+assistant messages of the session (the resolution/decisions) +- `match_message_id`, `messages_before`, `messages_after` + +Bookends + window together reconstruct goal → match → resolution without paying for the whole transcript. Typical wall time: 15–50ms on a real session DB. + +**2. Scroll — pass `session_id` + `around_message_id`:** + +```python +session_search(session_id="20260510_174648_805cc2", around_message_id=590803, window=10) +``` + +Returns a window of ±`window` messages centered on the anchor. No FTS5, no bookends — just the slice. Use after a discovery call when you need more context than the ±5 default window. + +- To scroll **forward**: pass `messages[-1].id` back as `around_message_id` +- To scroll **backward**: pass `messages[0].id` back as `around_message_id` +- The boundary message appears in both windows as an orientation marker +- When `messages_before` or `messages_after` is less than `window`, you're at the start or end of the session + +Typical wall time: 1–2ms per scroll call. + +**3. Browse — no args:** + +```python +session_search() +``` + +Returns recent sessions chronologically (titles, previews, timestamps). Useful when the user asks "what was I working on" without naming a topic. ### FTS5 Query Syntax The search supports standard FTS5 query syntax: -- Simple keywords: `docker deployment` +- Simple keywords: `docker deployment` (FTS5 defaults to AND) - Phrases: `"exact phrase"` - Boolean: `docker OR kubernetes`, `python NOT java` - Prefix: `deploy*` +### Optional parameters + +- `sort` — `newest` or `oldest`, on top of FTS5 ranking. Omit for relevance-only ordering (the default; suitable for exploratory recall). Use `newest` for "where did we leave X" questions, `oldest` for "how did X start" questions. +- `role_filter` — comma-separated roles to include. Discovery defaults to `user,assistant` (tool output is usually noise). Pass `user,assistant,tool` to include tool output (debugging tool behaviour) or `tool` to search tool output only. + ### When It's Used The agent is prompted to use session search automatically: diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index 5f2c8d16a2..ec0a4a9250 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -853,7 +853,7 @@ Common gateway problems: - **Windows-specific issues** (`Alt+Enter` newline, WinError 10106, UTF-8 BOM config, test suite, line endings): see the dedicated **Windows-Specific Quirks** section above. ### Auxiliary models not working -If `auxiliary` tasks (vision, compression, session_search) fail silently, the `auto` provider can't find a backend. Either set `OPENROUTER_API_KEY` or `GOOGLE_API_KEY`, or explicitly configure each auxiliary task's provider: +If `auxiliary` tasks (vision, compression) fail silently, the `auto` provider can't find a backend. Either set `OPENROUTER_API_KEY` or `GOOGLE_API_KEY`, or explicitly configure each auxiliary task's provider: ```bash hermes config set auxiliary.vision.provider hermes config set auxiliary.vision.model diff --git a/website/sidebars.ts b/website/sidebars.ts index 1a0aa6fb0b..7ca300c9d5 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -89,6 +89,7 @@ const sidebars: SidebarsConfig = { 'user-guide/features/vision', 'user-guide/features/image-generation', 'user-guide/features/tts', + 'user-guide/features/deliverable-mode', ], }, {