Merge branch 'main' into bb/gui
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" ]
|
||||
|
||||
@@ -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."
|
||||
@@ -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 <<EOF
|
||||
Bootstrap Hermes Agent browser tools.
|
||||
|
||||
Installs Node.js (into ~/.hermes/node/), the agent-browser npm package,
|
||||
and the Playwright Chromium browser engine.
|
||||
|
||||
Options:
|
||||
--skip-chromium Install Node + agent-browser but skip Chromium download
|
||||
--yes, -y Accept the ~400 MB Chromium download without prompting
|
||||
-h, --help Show this help
|
||||
|
||||
Environment:
|
||||
HERMES_HOME Override Hermes data dir (default: \$HOME/.hermes)
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown option: $1"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# OS / arch detection
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
OS="unknown"
|
||||
case "$(uname -s)" in
|
||||
Linux*) OS="linux" ;;
|
||||
Darwin*) OS="macos" ;;
|
||||
*)
|
||||
log_error "Unsupported OS: $(uname -s)"
|
||||
log_info "Windows users: run scripts/bootstrap_browser_tools.ps1 in PowerShell."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
NODE_ARCH=""
|
||||
case "$(uname -m)" in
|
||||
x86_64) NODE_ARCH="x64" ;;
|
||||
aarch64|arm64) NODE_ARCH="arm64" ;;
|
||||
armv7l) NODE_ARCH="armv7l" ;;
|
||||
*)
|
||||
log_error "Unsupported architecture: $(uname -m)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
NODE_OS=""
|
||||
case "$OS" in
|
||||
linux) NODE_OS="linux" ;;
|
||||
macos) NODE_OS="darwin" ;;
|
||||
esac
|
||||
|
||||
DISTRO=""
|
||||
if [ -f /etc/os-release ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
DISTRO="${ID:-}"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Step 1: Node.js
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ensure_node() {
|
||||
# Already on PATH and recent enough?
|
||||
if command -v node >/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
|
||||
@@ -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
|
||||
+19
-44
@@ -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:
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
+119
-12
@@ -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=[])
|
||||
|
||||
+25
-12
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
+24
-2
@@ -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/<name> where <name> 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("/")
|
||||
|
||||
|
||||
@@ -17,6 +17,71 @@ function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; sch
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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 (
|
||||
<div className="grid gap-2 border border-border p-2">
|
||||
{Object.entries(value).map(([subKey, subVal]) => (
|
||||
<div key={subKey} className="grid gap-1">
|
||||
<Label className="text-xs text-muted-foreground">{subKey}</Label>
|
||||
<NestedValueEditor
|
||||
fieldKey={`${fieldKey}.${subKey}`}
|
||||
value={subVal}
|
||||
onChange={(next) => onChange({ ...value, [subKey]: next })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{value.map((item, index) => (
|
||||
<div key={`${fieldKey}.${index}`} className="grid gap-1">
|
||||
<Label className="text-xs text-muted-foreground">Item {index + 1}</Label>
|
||||
<NestedValueEditor
|
||||
fieldKey={`${fieldKey}.${index}`}
|
||||
value={item}
|
||||
onChange={(next) =>
|
||||
onChange(value.map((existing, i) => (i === index ? next : existing)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={formatScalar(value)}
|
||||
onChange={(e) => 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 (
|
||||
<div className="grid gap-3 border border-border p-3">
|
||||
<Label className="text-xs font-medium">{label}</Label>
|
||||
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||
<NestedValueEditor fieldKey={schemaKey} value={value} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.type === "boolean") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
@@ -114,26 +189,6 @@ export function AutoField({
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
const obj = value as Record<string, unknown>;
|
||||
return (
|
||||
<div className="grid gap-3 border border-border p-3">
|
||||
<Label className="text-xs font-medium">{label}</Label>
|
||||
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||
{Object.entries(obj).map(([subKey, subVal]) => (
|
||||
<div key={subKey} className="grid gap-1">
|
||||
<Label className="text-xs text-muted-foreground">{subKey}</Label>
|
||||
<Input
|
||||
value={String(subVal ?? "")}
|
||||
onChange={(e) => onChange({ ...obj, [subKey]: e.target.value })}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
|
||||
@@ -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)]",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
+148
-2
@@ -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,
|
||||
|
||||
+42
-4
@@ -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
|
||||
|
||||
+71
-18
@@ -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
|
||||
|
||||
+128
-133
@@ -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 <valid_provider>'"
|
||||
(
|
||||
f"model.provider '{provider_raw}' is unknown. "
|
||||
f"Valid providers: {known_list}. "
|
||||
f"Fix: run 'hermes config set model.provider <valid_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 <name>'"
|
||||
(
|
||||
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 <name>'"
|
||||
),
|
||||
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:
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
|
||||
+163
-10
@@ -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 ``<log>`` to ``<log>.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 ``<log>`` when it exceeds ``max_bytes``.
|
||||
|
||||
``backup_count=1`` preserves the legacy single-generation behavior:
|
||||
``<log>`` moves to ``<log>.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 <name>`
|
||||
# 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():
|
||||
|
||||
@@ -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 "<task_id>"
|
||||
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_<hex>``
|
||||
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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+15
-22
@@ -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 },
|
||||
|
||||
-5
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
+160
-2
@@ -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
|
||||
|
||||
+88
-24
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
+35
-76
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
+41
-24
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({}) == {}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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
|
||||
|
||||
+65
-1
@@ -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": [],
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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) |
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -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 <your_provider>
|
||||
hermes config set auxiliary.vision.model <model_name>
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user