Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b277962dcc | |||
| 04068c5891 | |||
| 9f22977fc0 | |||
| 6b408e131c | |||
| eba7c869bb | |||
| 22efc81cd7 | |||
| 0cff992f0a | |||
| 64a1368210 | |||
| 649ef5c8f1 | |||
| 2081b71c42 | |||
| 9d7aac7ed2 | |||
| afd08b76c5 | |||
| be472138f3 | |||
| 8f4db7bbd5 | |||
| 654d61ab6f | |||
| a2b5627e6d | |||
| 09ced16ecc | |||
| 177e6eb3da | |||
| 22655ed1e6 | |||
| 2614586306 | |||
| 93f9db59b2 | |||
| 954dd8a4e0 | |||
| c470a325f7 | |||
| 1ec4a34dcd | |||
| b869bf206c | |||
| acca428c81 | |||
| 49282b6e04 | |||
| d990fa52ed | |||
| 520edd3499 | |||
| 60236862ee | |||
| 8a6aa5882e | |||
| f23123e7b4 | |||
| a5063ff105 | |||
| 2d59afd3da | |||
| 4c50b4689e | |||
| 4f24db4258 | |||
| 03e3c22e86 | |||
| 440764e013 | |||
| 12c8cefbce | |||
| afba54364e | |||
| 6ab78401c9 | |||
| 904f20d622 | |||
| edf1aecacd | |||
| e96758291b | |||
| fd5df5fe8e | |||
| 9d88bdaf11 | |||
| 098d554aac | |||
| 088bf9057f | |||
| e485bc60cd | |||
| 9b60ffc47f | |||
| 8155ebd7c4 | |||
| a33e890644 | |||
| e330112aa8 | |||
| 1e18e0503f | |||
| 5157f5427f | |||
| 6c0c625952 | |||
| fc5fda5e38 | |||
| 6a228d52f7 | |||
| 35e7bf6b00 | |||
| a4ba0754ed | |||
| b53f74a489 | |||
| 65a31ee0d5 | |||
| 491cf25eef | |||
| 52a972e927 | |||
| be3bec55be | |||
| 1ee3b79f1d | |||
| 7282652655 | |||
| ca3a0bbc54 | |||
| 519faa6e76 | |||
| 48cb8d20b2 | |||
| 09195be979 | |||
| bdfb0604ad | |||
| 1cf1016e72 | |||
| 323e827f4a | |||
| b2f8e231dd | |||
| ad4680cf74 | |||
| c9b833feb3 | |||
| 88185e7147 | |||
| 5d01fc4e6f | |||
| 50d6799389 | |||
| 6f79b8f01d | |||
| 0d353ca6a8 | |||
| 424e9f36b0 | |||
| 5f0a91f31a | |||
| 73d0b08351 | |||
| d40a828a8b | |||
| abfc1847b7 | |||
| af53039dbc | |||
| d50a9b20d2 | |||
| a3a4932405 | |||
| a47f5d3ea2 | |||
| 19db7fa3d1 | |||
| 2f67ef92eb | |||
| c1949e844b | |||
| ddd28329ff | |||
| 823b6d08ed | |||
| d393104bad | |||
| 3dea497b20 | |||
| aa5bd09232 | |||
| d2c2e34469 | |||
| 60fd4b7d16 | |||
| 923539a46b |
@@ -3,8 +3,13 @@ name: Docker Build and Publish
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '**/*.py'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- 'Dockerfile'
|
||||
- 'docker/**'
|
||||
- '.github/workflows/docker-publish.yml'
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
@@ -49,6 +54,14 @@ jobs:
|
||||
|
||||
- name: Test image starts
|
||||
run: |
|
||||
# The image runs as the hermes user (UID 10000). GitHub Actions
|
||||
# creates /tmp/hermes-test root-owned by default, which hermes
|
||||
# can't write to — chown it to match the in-container UID before
|
||||
# bind-mounting. Real users doing `docker run -v ~/.hermes:...`
|
||||
# with their own UID hit the same issue and have their own
|
||||
# remediations (HERMES_UID env var, or chown locally).
|
||||
mkdir -p /tmp/hermes-test
|
||||
sudo chown -R 10000:10000 /tmp/hermes-test
|
||||
docker run --rm \
|
||||
-v /tmp/hermes-test:/opt/data \
|
||||
--entrypoint /opt/hermes/docker/entrypoint.sh \
|
||||
|
||||
@@ -3,14 +3,31 @@ name: Supply Chain Audit
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- '**/*.py'
|
||||
- '**/*.pth'
|
||||
- '**/setup.py'
|
||||
- '**/setup.cfg'
|
||||
- '**/sitecustomize.py'
|
||||
- '**/usercustomize.py'
|
||||
- '**/__init__.pth'
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
# Narrow, high-signal scanner. Only fires on critical indicators of supply
|
||||
# chain attacks (e.g. the litellm-style payloads). Low-signal heuristics
|
||||
# (plain base64, plain exec/eval, dependency/Dockerfile/workflow edits,
|
||||
# Actions version unpinning, outbound POST/PUT) were intentionally
|
||||
# removed — they fired on nearly every PR and trained reviewers to ignore
|
||||
# the scanner. Keep this file's checks ruthlessly narrow: if you find
|
||||
# yourself adding WARNING-tier patterns here again, make a separate
|
||||
# advisory-only workflow instead.
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan PR for supply chain risks
|
||||
name: Scan PR for critical supply chain risks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -18,7 +35,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Scan diff for suspicious patterns
|
||||
- name: Scan diff for critical patterns
|
||||
id: scan
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -28,19 +45,19 @@ jobs:
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
# Get the full diff (added lines only)
|
||||
# Added lines only, excluding lockfiles.
|
||||
DIFF=$(git diff "$BASE".."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
|
||||
|
||||
FINDINGS=""
|
||||
CRITICAL=false
|
||||
|
||||
# --- .pth files (auto-execute on Python startup) ---
|
||||
# The exact mechanism used in the litellm supply chain attack:
|
||||
# https://github.com/BerriAI/litellm/issues/24512
|
||||
PTH_FILES=$(git diff --name-only "$BASE".."$HEAD" | grep '\.pth$' || true)
|
||||
if [ -n "$PTH_FILES" ]; then
|
||||
CRITICAL=true
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: .pth file added or modified
|
||||
Python \`.pth\` files in \`site-packages/\` execute automatically when the interpreter starts — no import required. This is the exact mechanism used in the [litellm supply chain attack](https://github.com/BerriAI/litellm/issues/24512).
|
||||
Python \`.pth\` files in \`site-packages/\` execute automatically when the interpreter starts — no import required.
|
||||
|
||||
**Files:**
|
||||
\`\`\`
|
||||
@@ -49,13 +66,12 @@ jobs:
|
||||
"
|
||||
fi
|
||||
|
||||
# --- base64 + exec/eval combo (the litellm attack pattern) ---
|
||||
# --- base64 decode + exec/eval on the same line (the litellm attack pattern) ---
|
||||
B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
|
||||
if [ -n "$B64_EXEC_HITS" ]; then
|
||||
CRITICAL=true
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: base64 decode + exec/eval combo
|
||||
This is the exact pattern used in the [litellm supply chain attack](https://github.com/BerriAI/litellm/issues/24512) — base64-decoded strings passed to exec/eval to hide credential-stealing payloads.
|
||||
Base64-decoded strings passed directly to exec/eval — the signature of hidden credential-stealing payloads.
|
||||
|
||||
**Matches:**
|
||||
\`\`\`
|
||||
@@ -64,41 +80,12 @@ jobs:
|
||||
"
|
||||
fi
|
||||
|
||||
# --- base64 decode/encode (alone — legitimate uses exist) ---
|
||||
B64_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|b64encode|decodebytes|encodebytes|urlsafe_b64decode)|atob\(|btoa\(|Buffer\.from\(.*base64' | head -20 || true)
|
||||
if [ -n "$B64_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### ⚠️ WARNING: base64 encoding/decoding detected
|
||||
Base64 has legitimate uses (images, JWT, etc.) but is also commonly used to obfuscate malicious payloads. Verify the usage is appropriate.
|
||||
|
||||
**Matches (first 20):**
|
||||
\`\`\`
|
||||
${B64_HITS}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
# --- exec/eval with string arguments ---
|
||||
EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E '(exec|eval)\s*\(' | grep -v '^\+\s*#' | grep -v 'test_\|mock\|assert\|# ' | head -20 || true)
|
||||
if [ -n "$EXEC_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### ⚠️ WARNING: exec() or eval() usage
|
||||
Dynamic code execution can hide malicious behavior, especially when combined with base64 or network fetches.
|
||||
|
||||
**Matches (first 20):**
|
||||
\`\`\`
|
||||
${EXEC_HITS}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
# --- subprocess with encoded/obfuscated commands ---
|
||||
PROC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|decode|encode|\\x|chr\(' | head -10 || true)
|
||||
# --- subprocess with encoded/obfuscated command argument ---
|
||||
PROC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true)
|
||||
if [ -n "$PROC_HITS" ]; then
|
||||
CRITICAL=true
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: subprocess with encoded/obfuscated command
|
||||
Subprocess calls with encoded arguments are a strong indicator of payload execution.
|
||||
Subprocess calls whose command strings are base64- or hex-encoded are a strong indicator of payload execution.
|
||||
|
||||
**Matches:**
|
||||
\`\`\`
|
||||
@@ -107,25 +94,12 @@ jobs:
|
||||
"
|
||||
fi
|
||||
|
||||
# --- Network calls to non-standard domains ---
|
||||
EXFIL_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'requests\.(post|put)\(|httpx\.(post|put)\(|urllib\.request\.urlopen' | grep -v '^\+\s*#' | grep -v 'test_\|mock\|assert' | head -10 || true)
|
||||
if [ -n "$EXFIL_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### ⚠️ WARNING: Outbound network calls (POST/PUT)
|
||||
Outbound POST/PUT requests in new code could be data exfiltration. Verify the destination URLs are legitimate.
|
||||
|
||||
**Matches (first 10):**
|
||||
\`\`\`
|
||||
${EXFIL_HITS}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
# --- setup.py / setup.cfg install hooks ---
|
||||
SETUP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(setup\.py|setup\.cfg|__init__\.pth|sitecustomize\.py|usercustomize\.py)$' || true)
|
||||
# --- Install-hook files (setup.py/sitecustomize/usercustomize/__init__.pth) ---
|
||||
# These execute during pip install or interpreter startup.
|
||||
SETUP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(^|/)(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
|
||||
if [ -n "$SETUP_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### ⚠️ WARNING: Install hook files modified
|
||||
### 🚨 CRITICAL: Install-hook file added or modified
|
||||
These files can execute code during package installation or interpreter startup.
|
||||
|
||||
**Files:**
|
||||
@@ -135,114 +109,31 @@ jobs:
|
||||
"
|
||||
fi
|
||||
|
||||
# --- Compile/marshal/pickle (code object injection) ---
|
||||
MARSHAL_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'marshal\.loads|pickle\.loads|compile\(' | grep -v '^\+\s*#' | grep -v 'test_\|re\.compile\|ast\.compile' | head -10 || true)
|
||||
if [ -n "$MARSHAL_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### ⚠️ WARNING: marshal/pickle/compile usage
|
||||
These can deserialize or construct executable code objects.
|
||||
|
||||
**Matches:**
|
||||
\`\`\`
|
||||
${MARSHAL_HITS}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
# --- CI/CD workflow files modified ---
|
||||
WORKFLOW_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '\.github/workflows/.*\.ya?ml$' || true)
|
||||
if [ -n "$WORKFLOW_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### ⚠️ WARNING: CI/CD workflow files modified
|
||||
Changes to workflow files can alter build pipelines, inject steps, or modify permissions. Verify no unauthorized actions or secrets access were added.
|
||||
|
||||
**Files:**
|
||||
\`\`\`
|
||||
${WORKFLOW_HITS}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
# --- Dockerfile / container build files modified ---
|
||||
DOCKER_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -iE '(Dockerfile|\.dockerignore|docker-compose)' || true)
|
||||
if [ -n "$DOCKER_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### ⚠️ WARNING: Container build files modified
|
||||
Changes to Dockerfiles or compose files can alter base images, add build steps, or expose ports. Verify base image pins and build commands.
|
||||
|
||||
**Files:**
|
||||
\`\`\`
|
||||
${DOCKER_HITS}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
# --- Dependency manifest files modified ---
|
||||
DEP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(pyproject\.toml|requirements.*\.txt|package\.json|Gemfile|go\.mod|Cargo\.toml)$' || true)
|
||||
if [ -n "$DEP_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### ⚠️ WARNING: Dependency manifest files modified
|
||||
Changes to dependency files can introduce new packages or change version pins. Verify all dependency changes are intentional and from trusted sources.
|
||||
|
||||
**Files:**
|
||||
\`\`\`
|
||||
${DEP_HITS}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
# --- GitHub Actions version unpinning (mutable tags instead of SHAs) ---
|
||||
ACTIONS_UNPIN=$(echo "$DIFF" | grep -n '^\+' | grep 'uses:' | grep -v '#' | grep -E '@v[0-9]' | head -10 || true)
|
||||
if [ -n "$ACTIONS_UNPIN" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### ⚠️ WARNING: GitHub Actions with mutable version tags
|
||||
Actions should be pinned to full commit SHAs (not \`@v4\`, \`@v5\`). Mutable tags can be retargeted silently if a maintainer account is compromised.
|
||||
|
||||
**Matches:**
|
||||
\`\`\`
|
||||
${ACTIONS_UNPIN}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
# --- Output results ---
|
||||
if [ -n "$FINDINGS" ]; then
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
if [ "$CRITICAL" = true ]; then
|
||||
echo "critical=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "critical=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# Write findings to a file (multiline env vars are fragile)
|
||||
echo "$FINDINGS" > /tmp/findings.md
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
echo "critical=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post warning comment
|
||||
- name: Post critical finding comment
|
||||
if: steps.scan.outputs.found == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
SEVERITY="⚠️ Supply Chain Risk Detected"
|
||||
if [ "${{ steps.scan.outputs.critical }}" = "true" ]; then
|
||||
SEVERITY="🚨 CRITICAL Supply Chain Risk Detected"
|
||||
fi
|
||||
BODY="## 🚨 CRITICAL Supply Chain Risk Detected
|
||||
|
||||
BODY="## ${SEVERITY}
|
||||
|
||||
This PR contains patterns commonly associated with supply chain attacks. This does **not** mean the PR is malicious — but these patterns require careful human review before merging.
|
||||
This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.
|
||||
|
||||
$(cat /tmp/findings.md)
|
||||
|
||||
---
|
||||
*Automated scan triggered by [supply-chain-audit](/.github/workflows/supply-chain-audit.yml). If this is a false positive, a maintainer can approve after manual review.*"
|
||||
*Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.*"
|
||||
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
|
||||
|
||||
- name: Fail on critical findings
|
||||
if: steps.scan.outputs.critical == 'true'
|
||||
if: steps.scan.outputs.found == 'true'
|
||||
run: |
|
||||
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
|
||||
exit 1
|
||||
|
||||
@@ -3,8 +3,14 @@ name: Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -17,7 +23,7 @@ concurrency:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
@@ -54,6 +54,11 @@ environments/benchmarks/evals/
|
||||
# Web UI build output
|
||||
hermes_cli/web_dist/
|
||||
|
||||
# Web UI assets — synced from @nous-research/ui at build time via
|
||||
# `npm run sync-assets` (see web/package.json).
|
||||
web/public/fonts/
|
||||
web/public/ds-assets/
|
||||
|
||||
# Release script temp files
|
||||
.release_notes.md
|
||||
mini-swe-agent/
|
||||
|
||||
@@ -20,6 +20,46 @@ from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
# Methods clients send as periodic liveness probes. They are not part of the
|
||||
# ACP schema, so the acp router correctly returns JSON-RPC -32601 to the
|
||||
# caller — but the supervisor task that dispatches the request then surfaces
|
||||
# the raised RequestError via ``logging.exception("Background task failed")``,
|
||||
# which dumps a traceback to stderr every probe interval. Clients like
|
||||
# acp-bridge already treat the -32601 response as "agent alive", so the
|
||||
# traceback is pure noise. We keep the protocol response intact and only
|
||||
# silence the stderr noise for this specific benign case.
|
||||
_BENIGN_PROBE_METHODS = frozenset({"ping", "health", "healthcheck"})
|
||||
|
||||
|
||||
class _BenignProbeMethodFilter(logging.Filter):
|
||||
"""Suppress acp 'Background task failed' tracebacks caused by unknown
|
||||
liveness-probe methods (e.g. ``ping``) while leaving every other
|
||||
background-task error — including method_not_found for any non-probe
|
||||
method — visible in stderr.
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if record.getMessage() != "Background task failed":
|
||||
return True
|
||||
exc_info = record.exc_info
|
||||
if not exc_info:
|
||||
return True
|
||||
exc = exc_info[1]
|
||||
# Imported lazily so this module stays importable when the optional
|
||||
# ``agent-client-protocol`` dependency is not installed.
|
||||
try:
|
||||
from acp.exceptions import RequestError
|
||||
except ImportError:
|
||||
return True
|
||||
if not isinstance(exc, RequestError):
|
||||
return True
|
||||
if getattr(exc, "code", None) != -32601:
|
||||
return True
|
||||
data = getattr(exc, "data", None)
|
||||
method = data.get("method") if isinstance(data, dict) else None
|
||||
return method not in _BENIGN_PROBE_METHODS
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
"""Route all logging to stderr so stdout stays clean for ACP stdio."""
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
@@ -29,6 +69,7 @@ def _setup_logging() -> None:
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
handler.addFilter(_BenignProbeMethodFilter())
|
||||
root = logging.getLogger()
|
||||
root.handlers.clear()
|
||||
root.addHandler(handler)
|
||||
|
||||
+121
-26
@@ -116,8 +116,25 @@ _KIMI_THINKING_MODELS: frozenset = frozenset({
|
||||
"kimi-k2-thinking-turbo",
|
||||
})
|
||||
|
||||
# Moonshot's public chat endpoint (api.moonshot.ai/v1) enforces a different
|
||||
# temperature contract than the Coding Plan endpoint above. Empirically,
|
||||
# `kimi-k2.5` on the public API rejects 0.6 with HTTP 400
|
||||
# "invalid temperature: only 1 is allowed for this model" — the Coding Plan
|
||||
# lock (0.6 for non-thinking) does not apply. `kimi-k2-turbo-preview` and the
|
||||
# thinking variants already match the Coding Plan contract on the public
|
||||
# endpoint, so we only override the models that diverge.
|
||||
# Users hit this endpoint when `KIMI_API_KEY` is a legacy `sk-*` key (the
|
||||
# `sk-kimi-*` prefix routes to api.kimi.com/coding/v1 instead — see
|
||||
# hermes_cli/auth.py:_kimi_base_url_for_key).
|
||||
_KIMI_PUBLIC_API_OVERRIDES: Dict[str, float] = {
|
||||
"kimi-k2.5": 1.0,
|
||||
}
|
||||
|
||||
def _fixed_temperature_for_model(model: Optional[str]) -> Optional[float]:
|
||||
|
||||
def _fixed_temperature_for_model(
|
||||
model: Optional[str],
|
||||
base_url: Optional[str] = None,
|
||||
) -> Optional[float]:
|
||||
"""Return a required temperature override for models with strict contracts.
|
||||
|
||||
Moonshot's kimi-for-coding endpoint rejects any non-approved temperature on
|
||||
@@ -125,15 +142,31 @@ def _fixed_temperature_for_model(model: Optional[str]) -> Optional[float]:
|
||||
variants require 1.0. An optional ``vendor/`` prefix (e.g.
|
||||
``moonshotai/kimi-k2.5``) is tolerated for aggregator routings.
|
||||
|
||||
When ``base_url`` points to Moonshot's public chat endpoint
|
||||
(``api.moonshot.ai``), the contract changes for ``kimi-k2.5``: the public
|
||||
API only accepts ``temperature=1``, not 0.6. That override takes precedence
|
||||
over the Coding Plan defaults above.
|
||||
|
||||
Returns ``None`` for every other model, including ``kimi-k2-instruct*``
|
||||
which is the separate non-coding K2 family with variable temperature.
|
||||
"""
|
||||
normalized = (model or "").strip().lower()
|
||||
bare = normalized.rsplit("/", 1)[-1]
|
||||
|
||||
# Public Moonshot API has a stricter contract for some models than the
|
||||
# Coding Plan endpoint — check it first so it wins on conflict.
|
||||
if base_url and ("api.moonshot.ai" in base_url.lower() or "api.moonshot.cn" in base_url.lower()):
|
||||
public = _KIMI_PUBLIC_API_OVERRIDES.get(bare)
|
||||
if public is not None:
|
||||
logger.debug(
|
||||
"Forcing temperature=%s for %r on public Moonshot API", public, model
|
||||
)
|
||||
return public
|
||||
|
||||
fixed = _FIXED_TEMPERATURE_MODELS.get(normalized)
|
||||
if fixed is not None:
|
||||
logger.debug("Forcing temperature=%s for model %r (fixed map)", fixed, model)
|
||||
return fixed
|
||||
bare = normalized.rsplit("/", 1)[-1]
|
||||
if bare in _KIMI_THINKING_MODELS:
|
||||
logger.debug("Forcing temperature=1.0 for kimi thinking model %r", model)
|
||||
return 1.0
|
||||
@@ -814,6 +847,11 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
if model is None:
|
||||
continue # skip provider if we don't know a valid aux model
|
||||
logger.debug("Auxiliary text client: %s (%s) via pool", pconfig.name, model)
|
||||
if provider_id == "gemini":
|
||||
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url
|
||||
|
||||
if is_native_gemini_base_url(base_url):
|
||||
return GeminiNativeClient(api_key=api_key, base_url=base_url), model
|
||||
extra = {}
|
||||
if "api.kimi.com" in base_url.lower():
|
||||
extra["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
|
||||
@@ -835,6 +873,11 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
if model is None:
|
||||
continue # skip provider if we don't know a valid aux model
|
||||
logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
|
||||
if provider_id == "gemini":
|
||||
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url
|
||||
|
||||
if is_native_gemini_base_url(base_url):
|
||||
return GeminiNativeClient(api_key=api_key, base_url=base_url), model
|
||||
extra = {}
|
||||
if "api.kimi.com" in base_url.lower():
|
||||
extra["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
|
||||
@@ -1055,7 +1098,7 @@ def _validate_base_url(base_url: str) -> None:
|
||||
) from exc
|
||||
|
||||
|
||||
def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
|
||||
runtime = _resolve_custom_runtime()
|
||||
if len(runtime) == 2:
|
||||
custom_base, custom_key = runtime
|
||||
@@ -1071,6 +1114,23 @@ def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
if custom_mode == "codex_responses":
|
||||
real_client = OpenAI(api_key=custom_key, base_url=custom_base)
|
||||
return CodexAuxiliaryClient(real_client, model), model
|
||||
if custom_mode == "anthropic_messages":
|
||||
# Third-party Anthropic-compatible gateway (MiniMax, Zhipu GLM,
|
||||
# LiteLLM proxies, etc.). Must NEVER be treated as OAuth —
|
||||
# Anthropic OAuth claims only apply to api.anthropic.com.
|
||||
try:
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
real_client = build_anthropic_client(custom_key, custom_base)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"Custom endpoint declares api_mode=anthropic_messages but the "
|
||||
"anthropic SDK is not installed — falling back to OpenAI-wire."
|
||||
)
|
||||
return OpenAI(api_key=custom_key, base_url=custom_base), model
|
||||
return (
|
||||
AnthropicAuxiliaryClient(real_client, model, custom_key, custom_base, is_oauth=False),
|
||||
model,
|
||||
)
|
||||
return OpenAI(api_key=custom_key, base_url=custom_base), model
|
||||
|
||||
|
||||
@@ -1391,6 +1451,13 @@ def _to_async_client(sync_client, model: str):
|
||||
return AsyncCodexAuxiliaryClient(sync_client), model
|
||||
if isinstance(sync_client, AnthropicAuxiliaryClient):
|
||||
return AsyncAnthropicAuxiliaryClient(sync_client), model
|
||||
try:
|
||||
from agent.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient
|
||||
|
||||
if isinstance(sync_client, GeminiNativeClient):
|
||||
return AsyncGeminiNativeClient(sync_client), model
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from agent.copilot_acp_client import CopilotACPClient
|
||||
if isinstance(sync_client, CopilotACPClient):
|
||||
@@ -1687,6 +1754,15 @@ def resolve_provider_client(
|
||||
default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "")
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
|
||||
if provider == "gemini":
|
||||
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url
|
||||
|
||||
if is_native_gemini_base_url(base_url):
|
||||
client = GeminiNativeClient(api_key=api_key, base_url=base_url)
|
||||
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
# Provider-specific headers
|
||||
headers = {}
|
||||
if "api.kimi.com" in base_url.lower():
|
||||
@@ -2237,7 +2313,6 @@ def _resolve_task_provider_model(
|
||||
to "custom" and the task uses that direct endpoint. api_mode is one of
|
||||
"chat_completions", "codex_responses", or None (auto-detect).
|
||||
"""
|
||||
config = {}
|
||||
cfg_provider = None
|
||||
cfg_model = None
|
||||
cfg_base_url = None
|
||||
@@ -2245,16 +2320,7 @@ def _resolve_task_provider_model(
|
||||
cfg_api_mode = None
|
||||
|
||||
if task:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
except ImportError:
|
||||
config = {}
|
||||
|
||||
aux = config.get("auxiliary", {}) if isinstance(config, dict) else {}
|
||||
task_config = aux.get(task, {}) if isinstance(aux, dict) else {}
|
||||
if not isinstance(task_config, dict):
|
||||
task_config = {}
|
||||
task_config = _get_auxiliary_task_config(task)
|
||||
cfg_provider = str(task_config.get("provider", "")).strip() or None
|
||||
cfg_model = str(task_config.get("model", "")).strip() or None
|
||||
cfg_base_url = str(task_config.get("base_url", "")).strip() or None
|
||||
@@ -2284,17 +2350,25 @@ def _resolve_task_provider_model(
|
||||
_DEFAULT_AUX_TIMEOUT = 30.0
|
||||
|
||||
|
||||
def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float:
|
||||
"""Read timeout from auxiliary.{task}.timeout in config, falling back to *default*."""
|
||||
def _get_auxiliary_task_config(task: str) -> Dict[str, Any]:
|
||||
"""Return the config dict for auxiliary.<task>, or {} when unavailable."""
|
||||
if not task:
|
||||
return default
|
||||
return {}
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
except ImportError:
|
||||
return default
|
||||
return {}
|
||||
aux = config.get("auxiliary", {}) if isinstance(config, dict) else {}
|
||||
task_config = aux.get(task, {}) if isinstance(aux, dict) else {}
|
||||
return task_config if isinstance(task_config, dict) else {}
|
||||
|
||||
|
||||
def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float:
|
||||
"""Read timeout from auxiliary.{task}.timeout in config, falling back to *default*."""
|
||||
if not task:
|
||||
return default
|
||||
task_config = _get_auxiliary_task_config(task)
|
||||
raw = task_config.get("timeout")
|
||||
if raw is not None:
|
||||
try:
|
||||
@@ -2304,6 +2378,15 @@ def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float
|
||||
return default
|
||||
|
||||
|
||||
def _get_task_extra_body(task: str) -> Dict[str, Any]:
|
||||
"""Read auxiliary.<task>.extra_body and return a shallow copy when valid."""
|
||||
task_config = _get_auxiliary_task_config(task)
|
||||
raw = task_config.get("extra_body")
|
||||
if isinstance(raw, dict):
|
||||
return dict(raw)
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Anthropic-compatible endpoint detection + image block conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2391,7 +2474,7 @@ def _build_call_kwargs(
|
||||
"timeout": timeout,
|
||||
}
|
||||
|
||||
fixed_temperature = _fixed_temperature_for_model(model)
|
||||
fixed_temperature = _fixed_temperature_for_model(model, base_url)
|
||||
if fixed_temperature is not None:
|
||||
temperature = fixed_temperature
|
||||
|
||||
@@ -2504,6 +2587,8 @@ def call_llm(
|
||||
"""
|
||||
resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
|
||||
task, provider, model, base_url, api_key)
|
||||
effective_extra_body = _get_task_extra_body(task)
|
||||
effective_extra_body.update(extra_body or {})
|
||||
|
||||
if task == "vision":
|
||||
effective_provider, client, final_model = resolve_vision_provider_client(
|
||||
@@ -2572,11 +2657,14 @@ def call_llm(
|
||||
task, resolved_provider or "auto", final_model or "default",
|
||||
f" at {_base_info}" if _base_info and "openrouter" not in _base_info else "")
|
||||
|
||||
# Pass the client's actual base_url (not just resolved_base_url) so
|
||||
# endpoint-specific temperature overrides can distinguish
|
||||
# api.moonshot.ai vs api.kimi.com/coding even on auto-detected routes.
|
||||
kwargs = _build_call_kwargs(
|
||||
resolved_provider, final_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout, extra_body=extra_body,
|
||||
base_url=resolved_base_url)
|
||||
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
|
||||
base_url=_base_info or resolved_base_url)
|
||||
|
||||
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
|
||||
_client_base = str(getattr(client, "base_url", "") or "")
|
||||
@@ -2630,7 +2718,8 @@ def call_llm(
|
||||
fb_label, fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=extra_body)
|
||||
extra_body=effective_extra_body,
|
||||
base_url=str(getattr(fb_client, "base_url", "") or ""))
|
||||
return _validate_llm_response(
|
||||
fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
raise
|
||||
@@ -2712,6 +2801,8 @@ async def async_call_llm(
|
||||
"""
|
||||
resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
|
||||
task, provider, model, base_url, api_key)
|
||||
effective_extra_body = _get_task_extra_body(task)
|
||||
effective_extra_body.update(extra_body or {})
|
||||
|
||||
if task == "vision":
|
||||
effective_provider, client, final_model = resolve_vision_provider_client(
|
||||
@@ -2765,14 +2856,17 @@ async def async_call_llm(
|
||||
|
||||
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
|
||||
|
||||
# Pass the client's actual base_url (not just resolved_base_url) so
|
||||
# endpoint-specific temperature overrides can distinguish
|
||||
# api.moonshot.ai vs api.kimi.com/coding even on auto-detected routes.
|
||||
_client_base = str(getattr(client, "base_url", "") or "")
|
||||
kwargs = _build_call_kwargs(
|
||||
resolved_provider, final_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout, extra_body=extra_body,
|
||||
base_url=resolved_base_url)
|
||||
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
|
||||
base_url=_client_base or resolved_base_url)
|
||||
|
||||
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
|
||||
_client_base = str(getattr(client, "base_url", "") or "")
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
|
||||
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
|
||||
|
||||
@@ -2808,7 +2902,8 @@ async def async_call_llm(
|
||||
fb_label, fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=extra_body)
|
||||
extra_body=effective_extra_body,
|
||||
base_url=str(getattr(fb_client, "base_url", "") or ""))
|
||||
# Convert sync fallback client to async
|
||||
async_fb, async_fb_model = _to_async_client(fb_client, fb_model or "")
|
||||
if async_fb_model and async_fb_model != fb_kwargs.get("model"):
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
"""Codex Responses API adapter.
|
||||
|
||||
Pure format-conversion and normalization logic for the OpenAI Responses API
|
||||
(used by OpenAI Codex, xAI, GitHub Models, and other Responses-compatible endpoints).
|
||||
|
||||
Extracted from run_agent.py to isolate Responses API-specific logic from the
|
||||
core agent loop. All functions are stateless — they operate on the data passed
|
||||
in and return transformed results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.prompt_builder import DEFAULT_AGENT_IDENTITY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str:
|
||||
"""Generate a deterministic call_id from tool call content.
|
||||
|
||||
Used as a fallback when the API doesn't provide a call_id.
|
||||
Deterministic IDs prevent cache invalidation — random UUIDs would
|
||||
make every API call's prefix unique, breaking OpenAI's prompt cache.
|
||||
"""
|
||||
seed = f"{fn_name}:{arguments}:{index}"
|
||||
digest = hashlib.sha256(seed.encode("utf-8", errors="replace")).hexdigest()[:12]
|
||||
return f"call_{digest}"
|
||||
|
||||
|
||||
def _split_responses_tool_id(raw_id: Any) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Split a stored tool id into (call_id, response_item_id)."""
|
||||
if not isinstance(raw_id, str):
|
||||
return None, None
|
||||
value = raw_id.strip()
|
||||
if not value:
|
||||
return None, None
|
||||
if "|" in value:
|
||||
call_id, response_item_id = value.split("|", 1)
|
||||
call_id = call_id.strip() or None
|
||||
response_item_id = response_item_id.strip() or None
|
||||
return call_id, response_item_id
|
||||
if value.startswith("fc_"):
|
||||
return None, value
|
||||
return value, None
|
||||
|
||||
|
||||
def _derive_responses_function_call_id(
|
||||
call_id: str,
|
||||
response_item_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build a valid Responses `function_call.id` (must start with `fc_`)."""
|
||||
if isinstance(response_item_id, str):
|
||||
candidate = response_item_id.strip()
|
||||
if candidate.startswith("fc_"):
|
||||
return candidate
|
||||
|
||||
source = (call_id or "").strip()
|
||||
if source.startswith("fc_"):
|
||||
return source
|
||||
if source.startswith("call_") and len(source) > len("call_"):
|
||||
return f"fc_{source[len('call_'):]}"
|
||||
|
||||
sanitized = re.sub(r"[^A-Za-z0-9_-]", "", source)
|
||||
if sanitized.startswith("fc_"):
|
||||
return sanitized
|
||||
if sanitized.startswith("call_") and len(sanitized) > len("call_"):
|
||||
return f"fc_{sanitized[len('call_'):]}"
|
||||
if sanitized:
|
||||
return f"fc_{sanitized[:48]}"
|
||||
|
||||
seed = source or str(response_item_id or "") or uuid.uuid4().hex
|
||||
digest = hashlib.sha1(seed.encode("utf-8")).hexdigest()[:24]
|
||||
return f"fc_{digest}"
|
||||
|
||||
|
||||
def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Convert chat-completions tool schemas to Responses function-tool schemas."""
|
||||
source_tools = tools
|
||||
if not source_tools:
|
||||
return None
|
||||
|
||||
converted: List[Dict[str, Any]] = []
|
||||
for item in source_tools:
|
||||
fn = item.get("function", {}) if isinstance(item, dict) else {}
|
||||
name = fn.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
converted.append({
|
||||
"type": "function",
|
||||
"name": name,
|
||||
"description": fn.get("description", ""),
|
||||
"strict": False,
|
||||
"parameters": fn.get("parameters", {"type": "object", "properties": {}}),
|
||||
})
|
||||
return converted or None
|
||||
|
||||
|
||||
def _chat_messages_to_responses_input(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Convert internal chat-style messages to Responses input items."""
|
||||
items: List[Dict[str, Any]] = []
|
||||
seen_item_ids: set = set()
|
||||
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
role = msg.get("role")
|
||||
if role == "system":
|
||||
continue
|
||||
|
||||
if role in {"user", "assistant"}:
|
||||
content = msg.get("content", "")
|
||||
content_text = str(content) if content is not None else ""
|
||||
|
||||
if role == "assistant":
|
||||
# Replay encrypted reasoning items from previous turns
|
||||
# so the API can maintain coherent reasoning chains.
|
||||
codex_reasoning = msg.get("codex_reasoning_items")
|
||||
has_codex_reasoning = False
|
||||
if isinstance(codex_reasoning, list):
|
||||
for ri in codex_reasoning:
|
||||
if isinstance(ri, dict) and ri.get("encrypted_content"):
|
||||
item_id = ri.get("id")
|
||||
if item_id and item_id in seen_item_ids:
|
||||
continue
|
||||
# Strip the "id" field — with store=False the
|
||||
# Responses API cannot look up items by ID and
|
||||
# returns 404. The encrypted_content blob is
|
||||
# self-contained for reasoning chain continuity.
|
||||
replay_item = {k: v for k, v in ri.items() if k != "id"}
|
||||
items.append(replay_item)
|
||||
if item_id:
|
||||
seen_item_ids.add(item_id)
|
||||
has_codex_reasoning = True
|
||||
|
||||
if content_text.strip():
|
||||
items.append({"role": "assistant", "content": content_text})
|
||||
elif has_codex_reasoning:
|
||||
# The Responses API requires a following item after each
|
||||
# reasoning item (otherwise: missing_following_item error).
|
||||
# When the assistant produced only reasoning with no visible
|
||||
# content, emit an empty assistant message as the required
|
||||
# following item.
|
||||
items.append({"role": "assistant", "content": ""})
|
||||
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if isinstance(tool_calls, list):
|
||||
for tc in tool_calls:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
fn = tc.get("function", {})
|
||||
fn_name = fn.get("name")
|
||||
if not isinstance(fn_name, str) or not fn_name.strip():
|
||||
continue
|
||||
|
||||
embedded_call_id, embedded_response_item_id = _split_responses_tool_id(
|
||||
tc.get("id")
|
||||
)
|
||||
call_id = tc.get("call_id")
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
call_id = embedded_call_id
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
if (
|
||||
isinstance(embedded_response_item_id, str)
|
||||
and embedded_response_item_id.startswith("fc_")
|
||||
and len(embedded_response_item_id) > len("fc_")
|
||||
):
|
||||
call_id = f"call_{embedded_response_item_id[len('fc_'):]}"
|
||||
else:
|
||||
_raw_args = str(fn.get("arguments", "{}"))
|
||||
call_id = _deterministic_call_id(fn_name, _raw_args, len(items))
|
||||
call_id = call_id.strip()
|
||||
|
||||
arguments = fn.get("arguments", "{}")
|
||||
if isinstance(arguments, dict):
|
||||
arguments = json.dumps(arguments, ensure_ascii=False)
|
||||
elif not isinstance(arguments, str):
|
||||
arguments = str(arguments)
|
||||
arguments = arguments.strip() or "{}"
|
||||
|
||||
items.append({
|
||||
"type": "function_call",
|
||||
"call_id": call_id,
|
||||
"name": fn_name,
|
||||
"arguments": arguments,
|
||||
})
|
||||
continue
|
||||
|
||||
items.append({"role": role, "content": content_text})
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
raw_tool_call_id = msg.get("tool_call_id")
|
||||
call_id, _ = _split_responses_tool_id(raw_tool_call_id)
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
if isinstance(raw_tool_call_id, str) and raw_tool_call_id.strip():
|
||||
call_id = raw_tool_call_id.strip()
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
continue
|
||||
items.append({
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": str(msg.get("content", "") or ""),
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]:
|
||||
if not isinstance(raw_items, list):
|
||||
raise ValueError("Codex Responses input must be a list of input items.")
|
||||
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
seen_ids: set = set()
|
||||
for idx, item in enumerate(raw_items):
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(f"Codex Responses input[{idx}] must be an object.")
|
||||
|
||||
item_type = item.get("type")
|
||||
if item_type == "function_call":
|
||||
call_id = item.get("call_id")
|
||||
name = item.get("name")
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
raise ValueError(f"Codex Responses input[{idx}] function_call is missing call_id.")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError(f"Codex Responses input[{idx}] function_call is missing name.")
|
||||
|
||||
arguments = item.get("arguments", "{}")
|
||||
if isinstance(arguments, dict):
|
||||
arguments = json.dumps(arguments, ensure_ascii=False)
|
||||
elif not isinstance(arguments, str):
|
||||
arguments = str(arguments)
|
||||
arguments = arguments.strip() or "{}"
|
||||
|
||||
normalized.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": call_id.strip(),
|
||||
"name": name.strip(),
|
||||
"arguments": arguments,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if item_type == "function_call_output":
|
||||
call_id = item.get("call_id")
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
raise ValueError(f"Codex Responses input[{idx}] function_call_output is missing call_id.")
|
||||
output = item.get("output", "")
|
||||
if output is None:
|
||||
output = ""
|
||||
if not isinstance(output, str):
|
||||
output = str(output)
|
||||
|
||||
normalized.append(
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id.strip(),
|
||||
"output": output,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if item_type == "reasoning":
|
||||
encrypted = item.get("encrypted_content")
|
||||
if isinstance(encrypted, str) and encrypted:
|
||||
item_id = item.get("id")
|
||||
if isinstance(item_id, str) and item_id:
|
||||
if item_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(item_id)
|
||||
reasoning_item = {"type": "reasoning", "encrypted_content": encrypted}
|
||||
# Do NOT include the "id" in the outgoing item — with
|
||||
# store=False (our default) the API tries to resolve the
|
||||
# id server-side and returns 404. The id is still used
|
||||
# above for local deduplication via seen_ids.
|
||||
summary = item.get("summary")
|
||||
if isinstance(summary, list):
|
||||
reasoning_item["summary"] = summary
|
||||
else:
|
||||
reasoning_item["summary"] = []
|
||||
normalized.append(reasoning_item)
|
||||
continue
|
||||
|
||||
role = item.get("role")
|
||||
if role in {"user", "assistant"}:
|
||||
content = item.get("content", "")
|
||||
if content is None:
|
||||
content = ""
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
|
||||
normalized.append({"role": role, "content": content})
|
||||
continue
|
||||
|
||||
raise ValueError(
|
||||
f"Codex Responses input[{idx}] has unsupported item shape (type={item_type!r}, role={role!r})."
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _preflight_codex_api_kwargs(
|
||||
api_kwargs: Any,
|
||||
*,
|
||||
allow_stream: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
if not isinstance(api_kwargs, dict):
|
||||
raise ValueError("Codex Responses request must be a dict.")
|
||||
|
||||
required = {"model", "instructions", "input"}
|
||||
missing = [key for key in required if key not in api_kwargs]
|
||||
if missing:
|
||||
raise ValueError(f"Codex Responses request missing required field(s): {', '.join(sorted(missing))}.")
|
||||
|
||||
model = api_kwargs.get("model")
|
||||
if not isinstance(model, str) or not model.strip():
|
||||
raise ValueError("Codex Responses request 'model' must be a non-empty string.")
|
||||
model = model.strip()
|
||||
|
||||
instructions = api_kwargs.get("instructions")
|
||||
if instructions is None:
|
||||
instructions = ""
|
||||
if not isinstance(instructions, str):
|
||||
instructions = str(instructions)
|
||||
instructions = instructions.strip() or DEFAULT_AGENT_IDENTITY
|
||||
|
||||
normalized_input = _preflight_codex_input_items(api_kwargs.get("input"))
|
||||
|
||||
tools = api_kwargs.get("tools")
|
||||
normalized_tools = None
|
||||
if tools is not None:
|
||||
if not isinstance(tools, list):
|
||||
raise ValueError("Codex Responses request 'tools' must be a list when provided.")
|
||||
normalized_tools = []
|
||||
for idx, tool in enumerate(tools):
|
||||
if not isinstance(tool, dict):
|
||||
raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
|
||||
if tool.get("type") != "function":
|
||||
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")
|
||||
|
||||
name = tool.get("name")
|
||||
parameters = tool.get("parameters")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError(f"Codex Responses tools[{idx}] is missing a valid name.")
|
||||
if not isinstance(parameters, dict):
|
||||
raise ValueError(f"Codex Responses tools[{idx}] is missing valid parameters.")
|
||||
|
||||
description = tool.get("description", "")
|
||||
if description is None:
|
||||
description = ""
|
||||
if not isinstance(description, str):
|
||||
description = str(description)
|
||||
|
||||
strict = tool.get("strict", False)
|
||||
if not isinstance(strict, bool):
|
||||
strict = bool(strict)
|
||||
|
||||
normalized_tools.append(
|
||||
{
|
||||
"type": "function",
|
||||
"name": name.strip(),
|
||||
"description": description,
|
||||
"strict": strict,
|
||||
"parameters": parameters,
|
||||
}
|
||||
)
|
||||
|
||||
store = api_kwargs.get("store", False)
|
||||
if store is not False:
|
||||
raise ValueError("Codex Responses contract requires 'store' to be false.")
|
||||
|
||||
allowed_keys = {
|
||||
"model", "instructions", "input", "tools", "store",
|
||||
"reasoning", "include", "max_output_tokens", "temperature",
|
||||
"tool_choice", "parallel_tool_calls", "prompt_cache_key", "service_tier",
|
||||
"extra_headers",
|
||||
}
|
||||
normalized: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"instructions": instructions,
|
||||
"input": normalized_input,
|
||||
"store": False,
|
||||
}
|
||||
if normalized_tools is not None:
|
||||
normalized["tools"] = normalized_tools
|
||||
|
||||
# Pass through reasoning config
|
||||
reasoning = api_kwargs.get("reasoning")
|
||||
if isinstance(reasoning, dict):
|
||||
normalized["reasoning"] = reasoning
|
||||
include = api_kwargs.get("include")
|
||||
if isinstance(include, list):
|
||||
normalized["include"] = include
|
||||
service_tier = api_kwargs.get("service_tier")
|
||||
if isinstance(service_tier, str) and service_tier.strip():
|
||||
normalized["service_tier"] = service_tier.strip()
|
||||
|
||||
# Pass through max_output_tokens and temperature
|
||||
max_output_tokens = api_kwargs.get("max_output_tokens")
|
||||
if isinstance(max_output_tokens, (int, float)) and max_output_tokens > 0:
|
||||
normalized["max_output_tokens"] = int(max_output_tokens)
|
||||
temperature = api_kwargs.get("temperature")
|
||||
if isinstance(temperature, (int, float)):
|
||||
normalized["temperature"] = float(temperature)
|
||||
|
||||
# Pass through tool_choice, parallel_tool_calls, prompt_cache_key
|
||||
for passthrough_key in ("tool_choice", "parallel_tool_calls", "prompt_cache_key"):
|
||||
val = api_kwargs.get(passthrough_key)
|
||||
if val is not None:
|
||||
normalized[passthrough_key] = val
|
||||
|
||||
extra_headers = api_kwargs.get("extra_headers")
|
||||
if extra_headers is not None:
|
||||
if not isinstance(extra_headers, dict):
|
||||
raise ValueError("Codex Responses request 'extra_headers' must be an object.")
|
||||
normalized_headers: Dict[str, str] = {}
|
||||
for key, value in extra_headers.items():
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
raise ValueError("Codex Responses request 'extra_headers' keys must be non-empty strings.")
|
||||
if value is None:
|
||||
continue
|
||||
normalized_headers[key.strip()] = str(value)
|
||||
if normalized_headers:
|
||||
normalized["extra_headers"] = normalized_headers
|
||||
|
||||
if allow_stream:
|
||||
stream = api_kwargs.get("stream")
|
||||
if stream is not None and stream is not True:
|
||||
raise ValueError("Codex Responses 'stream' must be true when set.")
|
||||
if stream is True:
|
||||
normalized["stream"] = True
|
||||
allowed_keys.add("stream")
|
||||
elif "stream" in api_kwargs:
|
||||
raise ValueError("Codex Responses stream flag is only allowed in fallback streaming requests.")
|
||||
|
||||
unexpected = sorted(key for key in api_kwargs if key not in allowed_keys)
|
||||
if unexpected:
|
||||
raise ValueError(
|
||||
f"Codex Responses request has unsupported field(s): {', '.join(unexpected)}."
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _extract_responses_message_text(item: Any) -> str:
|
||||
"""Extract assistant text from a Responses message output item."""
|
||||
content = getattr(item, "content", None)
|
||||
if not isinstance(content, list):
|
||||
return ""
|
||||
|
||||
chunks: List[str] = []
|
||||
for part in content:
|
||||
ptype = getattr(part, "type", None)
|
||||
if ptype not in {"output_text", "text"}:
|
||||
continue
|
||||
text = getattr(part, "text", None)
|
||||
if isinstance(text, str) and text:
|
||||
chunks.append(text)
|
||||
return "".join(chunks).strip()
|
||||
|
||||
|
||||
def _extract_responses_reasoning_text(item: Any) -> str:
|
||||
"""Extract a compact reasoning text from a Responses reasoning item."""
|
||||
summary = getattr(item, "summary", None)
|
||||
if isinstance(summary, list):
|
||||
chunks: List[str] = []
|
||||
for part in summary:
|
||||
text = getattr(part, "text", None)
|
||||
if isinstance(text, str) and text:
|
||||
chunks.append(text)
|
||||
if chunks:
|
||||
return "\n".join(chunks).strip()
|
||||
text = getattr(item, "text", None)
|
||||
if isinstance(text, str) and text:
|
||||
return text.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_codex_response(response: Any) -> tuple[Any, str]:
|
||||
"""Normalize a Responses API object to an assistant_message-like object."""
|
||||
output = getattr(response, "output", None)
|
||||
if not isinstance(output, list) or not output:
|
||||
# The Codex backend can return empty output when the answer was
|
||||
# delivered entirely via stream events. Check output_text as a
|
||||
# last-resort fallback before raising.
|
||||
out_text = getattr(response, "output_text", None)
|
||||
if isinstance(out_text, str) and out_text.strip():
|
||||
logger.debug(
|
||||
"Codex response has empty output but output_text is present (%d chars); "
|
||||
"synthesizing output item.", len(out_text.strip()),
|
||||
)
|
||||
output = [SimpleNamespace(
|
||||
type="message", role="assistant", status="completed",
|
||||
content=[SimpleNamespace(type="output_text", text=out_text.strip())],
|
||||
)]
|
||||
response.output = output
|
||||
else:
|
||||
raise RuntimeError("Responses API returned no output items")
|
||||
|
||||
response_status = getattr(response, "status", None)
|
||||
if isinstance(response_status, str):
|
||||
response_status = response_status.strip().lower()
|
||||
else:
|
||||
response_status = None
|
||||
|
||||
if response_status in {"failed", "cancelled"}:
|
||||
error_obj = getattr(response, "error", None)
|
||||
if isinstance(error_obj, dict):
|
||||
error_msg = error_obj.get("message") or str(error_obj)
|
||||
else:
|
||||
error_msg = str(error_obj) if error_obj else f"Responses API returned status '{response_status}'"
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
content_parts: List[str] = []
|
||||
reasoning_parts: List[str] = []
|
||||
reasoning_items_raw: List[Dict[str, Any]] = []
|
||||
tool_calls: List[Any] = []
|
||||
has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"}
|
||||
saw_commentary_phase = False
|
||||
saw_final_answer_phase = False
|
||||
|
||||
for item in output:
|
||||
item_type = getattr(item, "type", None)
|
||||
item_status = getattr(item, "status", None)
|
||||
if isinstance(item_status, str):
|
||||
item_status = item_status.strip().lower()
|
||||
else:
|
||||
item_status = None
|
||||
|
||||
if item_status in {"queued", "in_progress", "incomplete"}:
|
||||
has_incomplete_items = True
|
||||
|
||||
if item_type == "message":
|
||||
item_phase = getattr(item, "phase", None)
|
||||
if isinstance(item_phase, str):
|
||||
normalized_phase = item_phase.strip().lower()
|
||||
if normalized_phase in {"commentary", "analysis"}:
|
||||
saw_commentary_phase = True
|
||||
elif normalized_phase in {"final_answer", "final"}:
|
||||
saw_final_answer_phase = True
|
||||
message_text = _extract_responses_message_text(item)
|
||||
if message_text:
|
||||
content_parts.append(message_text)
|
||||
elif item_type == "reasoning":
|
||||
reasoning_text = _extract_responses_reasoning_text(item)
|
||||
if reasoning_text:
|
||||
reasoning_parts.append(reasoning_text)
|
||||
# Capture the full reasoning item for multi-turn continuity.
|
||||
# encrypted_content is an opaque blob the API needs back on
|
||||
# subsequent turns to maintain coherent reasoning chains.
|
||||
encrypted = getattr(item, "encrypted_content", None)
|
||||
if isinstance(encrypted, str) and encrypted:
|
||||
raw_item = {"type": "reasoning", "encrypted_content": encrypted}
|
||||
item_id = getattr(item, "id", None)
|
||||
if isinstance(item_id, str) and item_id:
|
||||
raw_item["id"] = item_id
|
||||
# Capture summary — required by the API when replaying reasoning items
|
||||
summary = getattr(item, "summary", None)
|
||||
if isinstance(summary, list):
|
||||
raw_summary = []
|
||||
for part in summary:
|
||||
text = getattr(part, "text", None)
|
||||
if isinstance(text, str):
|
||||
raw_summary.append({"type": "summary_text", "text": text})
|
||||
raw_item["summary"] = raw_summary
|
||||
reasoning_items_raw.append(raw_item)
|
||||
elif item_type == "function_call":
|
||||
if item_status in {"queued", "in_progress", "incomplete"}:
|
||||
continue
|
||||
fn_name = getattr(item, "name", "") or ""
|
||||
arguments = getattr(item, "arguments", "{}")
|
||||
if not isinstance(arguments, str):
|
||||
arguments = json.dumps(arguments, ensure_ascii=False)
|
||||
raw_call_id = getattr(item, "call_id", None)
|
||||
raw_item_id = getattr(item, "id", None)
|
||||
embedded_call_id, _ = _split_responses_tool_id(raw_item_id)
|
||||
call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
call_id = _deterministic_call_id(fn_name, arguments, len(tool_calls))
|
||||
call_id = call_id.strip()
|
||||
response_item_id = raw_item_id if isinstance(raw_item_id, str) else None
|
||||
response_item_id = _derive_responses_function_call_id(call_id, response_item_id)
|
||||
tool_calls.append(SimpleNamespace(
|
||||
id=call_id,
|
||||
call_id=call_id,
|
||||
response_item_id=response_item_id,
|
||||
type="function",
|
||||
function=SimpleNamespace(name=fn_name, arguments=arguments),
|
||||
))
|
||||
elif item_type == "custom_tool_call":
|
||||
fn_name = getattr(item, "name", "") or ""
|
||||
arguments = getattr(item, "input", "{}")
|
||||
if not isinstance(arguments, str):
|
||||
arguments = json.dumps(arguments, ensure_ascii=False)
|
||||
raw_call_id = getattr(item, "call_id", None)
|
||||
raw_item_id = getattr(item, "id", None)
|
||||
embedded_call_id, _ = _split_responses_tool_id(raw_item_id)
|
||||
call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
call_id = _deterministic_call_id(fn_name, arguments, len(tool_calls))
|
||||
call_id = call_id.strip()
|
||||
response_item_id = raw_item_id if isinstance(raw_item_id, str) else None
|
||||
response_item_id = _derive_responses_function_call_id(call_id, response_item_id)
|
||||
tool_calls.append(SimpleNamespace(
|
||||
id=call_id,
|
||||
call_id=call_id,
|
||||
response_item_id=response_item_id,
|
||||
type="function",
|
||||
function=SimpleNamespace(name=fn_name, arguments=arguments),
|
||||
))
|
||||
|
||||
final_text = "\n".join([p for p in content_parts if p]).strip()
|
||||
if not final_text and hasattr(response, "output_text"):
|
||||
out_text = getattr(response, "output_text", "")
|
||||
if isinstance(out_text, str):
|
||||
final_text = out_text.strip()
|
||||
|
||||
assistant_message = SimpleNamespace(
|
||||
content=final_text,
|
||||
tool_calls=tool_calls,
|
||||
reasoning="\n\n".join(reasoning_parts).strip() if reasoning_parts else None,
|
||||
reasoning_content=None,
|
||||
reasoning_details=None,
|
||||
codex_reasoning_items=reasoning_items_raw or None,
|
||||
)
|
||||
|
||||
if tool_calls:
|
||||
finish_reason = "tool_calls"
|
||||
elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
|
||||
finish_reason = "incomplete"
|
||||
elif reasoning_items_raw and not final_text:
|
||||
# Response contains only reasoning (encrypted thinking state) with
|
||||
# no visible content or tool calls. The model is still thinking and
|
||||
# needs another turn to produce the actual answer. Marking this as
|
||||
# "stop" would send it into the empty-content retry loop which burns
|
||||
# 3 retries then fails — treat it as incomplete instead so the Codex
|
||||
# continuation path handles it correctly.
|
||||
finish_reason = "incomplete"
|
||||
else:
|
||||
finish_reason = "stop"
|
||||
return assistant_message, finish_reason
|
||||
|
||||
|
||||
@@ -483,9 +483,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
+10
-4
@@ -225,9 +225,11 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
content = _oneline(args.get("content", ""))
|
||||
return f"+{target}: \"{content[:25]}{'...' if len(content) > 25 else ''}\""
|
||||
elif action == "replace":
|
||||
return f"~{target}: \"{_oneline(args.get('old_text', '')[:20])}\""
|
||||
old = _oneline(args.get("old_text") or "") or "<missing old_text>"
|
||||
return f"~{target}: \"{old[:20]}\""
|
||||
elif action == "remove":
|
||||
return f"-{target}: \"{_oneline(args.get('old_text', '')[:20])}\""
|
||||
old = _oneline(args.get("old_text") or "") or "<missing old_text>"
|
||||
return f"-{target}: \"{old[:20]}\""
|
||||
return action
|
||||
|
||||
if tool_name == "send_message":
|
||||
@@ -939,9 +941,13 @@ def get_cute_tool_message(
|
||||
if action == "add":
|
||||
return _wrap(f"┊ 🧠 memory +{target}: \"{_trunc(args.get('content', ''), 30)}\" {dur}")
|
||||
elif action == "replace":
|
||||
return _wrap(f"┊ 🧠 memory ~{target}: \"{_trunc(args.get('old_text', ''), 20)}\" {dur}")
|
||||
old = args.get("old_text") or ""
|
||||
old = old if old else "<missing old_text>"
|
||||
return _wrap(f"┊ 🧠 memory ~{target}: \"{_trunc(old, 20)}\" {dur}")
|
||||
elif action == "remove":
|
||||
return _wrap(f"┊ 🧠 memory -{target}: \"{_trunc(args.get('old_text', ''), 20)}\" {dur}")
|
||||
old = args.get("old_text") or ""
|
||||
old = old if old else "<missing old_text>"
|
||||
return _wrap(f"┊ 🧠 memory -{target}: \"{_trunc(old, 20)}\" {dur}")
|
||||
return _wrap(f"┊ 🧠 memory {action} {dur}")
|
||||
if tool_name == "skills_list":
|
||||
return _wrap(f"┊ 📚 skills list {args.get('category', 'all')} {dur}")
|
||||
|
||||
@@ -290,7 +290,7 @@ def classify_api_error(
|
||||
if isinstance(body, dict):
|
||||
_err_obj = body.get("error", {})
|
||||
if isinstance(_err_obj, dict):
|
||||
_body_msg = (_err_obj.get("message") or "").lower()
|
||||
_body_msg = str(_err_obj.get("message") or "").lower()
|
||||
# Parse metadata.raw for wrapped provider errors
|
||||
_metadata = _err_obj.get("metadata", {})
|
||||
if isinstance(_metadata, dict):
|
||||
@@ -302,11 +302,11 @@ def classify_api_error(
|
||||
if isinstance(_inner, dict):
|
||||
_inner_err = _inner.get("error", {})
|
||||
if isinstance(_inner_err, dict):
|
||||
_metadata_msg = (_inner_err.get("message") or "").lower()
|
||||
_metadata_msg = str(_inner_err.get("message") or "").lower()
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
if not _body_msg:
|
||||
_body_msg = (body.get("message") or "").lower()
|
||||
_body_msg = str(body.get("message") or "").lower()
|
||||
# Combine all message sources for pattern matching
|
||||
parts = [_raw_msg]
|
||||
if _body_msg and _body_msg not in _raw_msg:
|
||||
@@ -606,10 +606,10 @@ def _classify_400(
|
||||
if isinstance(body, dict):
|
||||
err_obj = body.get("error", {})
|
||||
if isinstance(err_obj, dict):
|
||||
err_body_msg = (err_obj.get("message") or "").strip().lower()
|
||||
err_body_msg = str(err_obj.get("message") or "").strip().lower()
|
||||
# Responses API (and some providers) use flat body: {"message": "..."}
|
||||
if not err_body_msg:
|
||||
err_body_msg = (body.get("message") or "").strip().lower()
|
||||
err_body_msg = str(body.get("message") or "").strip().lower()
|
||||
is_generic = len(err_body_msg) < 30 or err_body_msg in ("error", "")
|
||||
is_large = approx_tokens > context_length * 0.4 or approx_tokens > 80000 or num_messages > 80
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ from typing import Any, Dict, Iterator, List, Optional
|
||||
import httpx
|
||||
|
||||
from agent import google_oauth
|
||||
from agent.gemini_schema import sanitize_gemini_tool_parameters
|
||||
from agent.google_code_assist import (
|
||||
CODE_ASSIST_ENDPOINT,
|
||||
FREE_TIER_ID,
|
||||
@@ -205,7 +206,7 @@ def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]:
|
||||
decl["description"] = str(fn["description"])
|
||||
params = fn.get("parameters")
|
||||
if isinstance(params, dict):
|
||||
decl["parameters"] = params
|
||||
decl["parameters"] = sanitize_gemini_tool_parameters(params)
|
||||
declarations.append(decl)
|
||||
if not declarations:
|
||||
return []
|
||||
@@ -504,9 +505,16 @@ def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]:
|
||||
def _translate_stream_event(
|
||||
event: Dict[str, Any],
|
||||
model: str,
|
||||
tool_call_indices: Dict[str, int],
|
||||
tool_call_counter: List[int],
|
||||
) -> List[_GeminiStreamChunk]:
|
||||
"""Unwrap Code Assist envelope and emit OpenAI-shaped chunk(s)."""
|
||||
"""Unwrap Code Assist envelope and emit OpenAI-shaped chunk(s).
|
||||
|
||||
``tool_call_counter`` is a single-element list used as a mutable counter
|
||||
across events in the same stream. Each ``functionCall`` part gets a
|
||||
fresh, unique OpenAI ``index`` — keying by function name would collide
|
||||
whenever the model issues parallel calls to the same tool (e.g. reading
|
||||
three files in one turn).
|
||||
"""
|
||||
inner = event.get("response") if isinstance(event.get("response"), dict) else event
|
||||
candidates = inner.get("candidates") or []
|
||||
if not candidates:
|
||||
@@ -532,7 +540,8 @@ def _translate_stream_event(
|
||||
fc = part.get("functionCall")
|
||||
if isinstance(fc, dict) and fc.get("name"):
|
||||
name = str(fc["name"])
|
||||
idx = tool_call_indices.setdefault(name, len(tool_call_indices))
|
||||
idx = tool_call_counter[0]
|
||||
tool_call_counter[0] += 1
|
||||
try:
|
||||
args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
@@ -549,7 +558,7 @@ def _translate_stream_event(
|
||||
finish_reason_raw = str(cand.get("finishReason") or "")
|
||||
if finish_reason_raw:
|
||||
mapped = _map_gemini_finish_reason(finish_reason_raw)
|
||||
if tool_call_indices:
|
||||
if tool_call_counter[0] > 0:
|
||||
mapped = "tool_calls"
|
||||
chunks.append(_make_stream_chunk(model=model, finish_reason=mapped))
|
||||
return chunks
|
||||
@@ -733,9 +742,9 @@ class GeminiCloudCodeClient:
|
||||
# Materialize error body for better diagnostics
|
||||
response.read()
|
||||
raise _gemini_http_error(response)
|
||||
tool_call_indices: Dict[str, int] = {}
|
||||
tool_call_counter: List[int] = [0]
|
||||
for event in _iter_sse_events(response):
|
||||
for chunk in _translate_stream_event(event, model, tool_call_indices):
|
||||
for chunk in _translate_stream_event(event, model, tool_call_counter):
|
||||
yield chunk
|
||||
except httpx.HTTPError as exc:
|
||||
raise CodeAssistError(
|
||||
|
||||
@@ -0,0 +1,846 @@
|
||||
"""OpenAI-compatible facade over Google AI Studio's native Gemini API.
|
||||
|
||||
Hermes keeps ``api_mode='chat_completions'`` for the ``gemini`` provider so the
|
||||
main agent loop can keep using its existing OpenAI-shaped message flow.
|
||||
This adapter is the transport shim that converts those OpenAI-style
|
||||
``messages[]`` / ``tools[]`` requests into Gemini's native
|
||||
``models/{model}:generateContent`` schema and converts the responses back.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
Google's OpenAI-compatible endpoint has been brittle for Hermes's multi-turn
|
||||
agent/tool loop (auth churn, tool-call replay quirks, thought-signature
|
||||
requirements). The native Gemini API is the canonical path and avoids the
|
||||
OpenAI-compat layer entirely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agent.gemini_schema import sanitize_gemini_tool_parameters
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
|
||||
def is_native_gemini_base_url(base_url: str) -> bool:
|
||||
"""Return True when the endpoint speaks Gemini's native REST API."""
|
||||
normalized = str(base_url or "").strip().rstrip("/").lower()
|
||||
if not normalized:
|
||||
return False
|
||||
if "generativelanguage.googleapis.com" not in normalized:
|
||||
return False
|
||||
return not normalized.endswith("/openai")
|
||||
|
||||
|
||||
class GeminiAPIError(Exception):
|
||||
"""Error shape compatible with Hermes retry/error classification."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str = "gemini_api_error",
|
||||
status_code: Optional[int] = None,
|
||||
response: Optional[httpx.Response] = None,
|
||||
retry_after: Optional[float] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
self.response = response
|
||||
self.retry_after = retry_after
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
def _coerce_content_to_text(content: Any) -> str:
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
pieces: List[str] = []
|
||||
for part in content:
|
||||
if isinstance(part, str):
|
||||
pieces.append(part)
|
||||
elif isinstance(part, dict) and part.get("type") == "text":
|
||||
text = part.get("text")
|
||||
if isinstance(text, str):
|
||||
pieces.append(text)
|
||||
return "\n".join(pieces)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _extract_multimodal_parts(content: Any) -> List[Dict[str, Any]]:
|
||||
if not isinstance(content, list):
|
||||
text = _coerce_content_to_text(content)
|
||||
return [{"text": text}] if text else []
|
||||
|
||||
parts: List[Dict[str, Any]] = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
parts.append({"text": item})
|
||||
continue
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
ptype = item.get("type")
|
||||
if ptype == "text":
|
||||
text = item.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
parts.append({"text": text})
|
||||
elif ptype == "image_url":
|
||||
url = ((item.get("image_url") or {}).get("url") or "")
|
||||
if not isinstance(url, str) or not url.startswith("data:"):
|
||||
continue
|
||||
try:
|
||||
header, encoded = url.split(",", 1)
|
||||
mime = header.split(":", 1)[1].split(";", 1)[0]
|
||||
raw = base64.b64decode(encoded)
|
||||
except Exception:
|
||||
continue
|
||||
parts.append(
|
||||
{
|
||||
"inlineData": {
|
||||
"mimeType": mime,
|
||||
"data": base64.b64encode(raw).decode("ascii"),
|
||||
}
|
||||
}
|
||||
)
|
||||
return parts
|
||||
|
||||
|
||||
def _tool_call_extra_signature(tool_call: Dict[str, Any]) -> Optional[str]:
|
||||
extra = tool_call.get("extra_content") or {}
|
||||
if not isinstance(extra, dict):
|
||||
return None
|
||||
google = extra.get("google") or extra.get("thought_signature")
|
||||
if isinstance(google, dict):
|
||||
sig = google.get("thought_signature") or google.get("thoughtSignature")
|
||||
return str(sig) if isinstance(sig, str) and sig else None
|
||||
if isinstance(google, str) and google:
|
||||
return google
|
||||
return None
|
||||
|
||||
|
||||
def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]:
|
||||
fn = tool_call.get("function") or {}
|
||||
args_raw = fn.get("arguments", "")
|
||||
try:
|
||||
args = json.loads(args_raw) if isinstance(args_raw, str) and args_raw else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {"_raw": args_raw}
|
||||
if not isinstance(args, dict):
|
||||
args = {"_value": args}
|
||||
|
||||
part: Dict[str, Any] = {
|
||||
"functionCall": {
|
||||
"name": str(fn.get("name") or ""),
|
||||
"args": args,
|
||||
}
|
||||
}
|
||||
thought_signature = _tool_call_extra_signature(tool_call)
|
||||
if thought_signature:
|
||||
part["thoughtSignature"] = thought_signature
|
||||
return part
|
||||
|
||||
|
||||
def _translate_tool_result_to_gemini(
|
||||
message: Dict[str, Any],
|
||||
tool_name_by_call_id: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
tool_name_by_call_id = tool_name_by_call_id or {}
|
||||
tool_call_id = str(message.get("tool_call_id") or "")
|
||||
name = str(
|
||||
message.get("name")
|
||||
or tool_name_by_call_id.get(tool_call_id)
|
||||
or tool_call_id
|
||||
or "tool"
|
||||
)
|
||||
content = _coerce_content_to_text(message.get("content"))
|
||||
try:
|
||||
parsed = json.loads(content) if content.strip().startswith(("{", "[")) else None
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
response = parsed if isinstance(parsed, dict) else {"output": content}
|
||||
return {
|
||||
"functionResponse": {
|
||||
"name": name,
|
||||
"response": response,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
system_text_parts: List[str] = []
|
||||
contents: List[Dict[str, Any]] = []
|
||||
tool_name_by_call_id: Dict[str, str] = {}
|
||||
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
role = str(msg.get("role") or "user")
|
||||
|
||||
if role == "system":
|
||||
system_text_parts.append(_coerce_content_to_text(msg.get("content")))
|
||||
continue
|
||||
|
||||
if role in {"tool", "function"}:
|
||||
contents.append(
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
_translate_tool_result_to_gemini(
|
||||
msg,
|
||||
tool_name_by_call_id=tool_name_by_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
gemini_role = "model" if role == "assistant" else "user"
|
||||
parts: List[Dict[str, Any]] = []
|
||||
|
||||
content_parts = _extract_multimodal_parts(msg.get("content"))
|
||||
parts.extend(content_parts)
|
||||
|
||||
tool_calls = msg.get("tool_calls") or []
|
||||
if isinstance(tool_calls, list):
|
||||
for tool_call in tool_calls:
|
||||
if isinstance(tool_call, dict):
|
||||
tool_call_id = str(tool_call.get("id") or tool_call.get("call_id") or "")
|
||||
tool_name = str(((tool_call.get("function") or {}).get("name") or ""))
|
||||
if tool_call_id and tool_name:
|
||||
tool_name_by_call_id[tool_call_id] = tool_name
|
||||
parts.append(_translate_tool_call_to_gemini(tool_call))
|
||||
|
||||
if parts:
|
||||
contents.append({"role": gemini_role, "parts": parts})
|
||||
|
||||
system_instruction = None
|
||||
joined_system = "\n".join(part for part in system_text_parts if part).strip()
|
||||
if joined_system:
|
||||
system_instruction = {"parts": [{"text": joined_system}]}
|
||||
return contents, system_instruction
|
||||
|
||||
|
||||
def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]:
|
||||
if not isinstance(tools, list):
|
||||
return []
|
||||
declarations: List[Dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict):
|
||||
continue
|
||||
fn = tool.get("function") or {}
|
||||
if not isinstance(fn, dict):
|
||||
continue
|
||||
name = fn.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
continue
|
||||
decl: Dict[str, Any] = {"name": name}
|
||||
description = fn.get("description")
|
||||
if isinstance(description, str) and description:
|
||||
decl["description"] = description
|
||||
parameters = fn.get("parameters")
|
||||
if isinstance(parameters, dict):
|
||||
decl["parameters"] = sanitize_gemini_tool_parameters(parameters)
|
||||
declarations.append(decl)
|
||||
return [{"functionDeclarations": declarations}] if declarations else []
|
||||
|
||||
|
||||
def _translate_tool_choice_to_gemini(tool_choice: Any) -> Optional[Dict[str, Any]]:
|
||||
if tool_choice is None:
|
||||
return None
|
||||
if isinstance(tool_choice, str):
|
||||
if tool_choice == "auto":
|
||||
return {"functionCallingConfig": {"mode": "AUTO"}}
|
||||
if tool_choice == "required":
|
||||
return {"functionCallingConfig": {"mode": "ANY"}}
|
||||
if tool_choice == "none":
|
||||
return {"functionCallingConfig": {"mode": "NONE"}}
|
||||
if isinstance(tool_choice, dict):
|
||||
fn = tool_choice.get("function") or {}
|
||||
name = fn.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
return {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": [name]}}
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]:
|
||||
if not isinstance(config, dict) or not config:
|
||||
return None
|
||||
budget = config.get("thinkingBudget", config.get("thinking_budget"))
|
||||
include = config.get("includeThoughts", config.get("include_thoughts"))
|
||||
level = config.get("thinkingLevel", config.get("thinking_level"))
|
||||
normalized: Dict[str, Any] = {}
|
||||
if isinstance(budget, (int, float)):
|
||||
normalized["thinkingBudget"] = int(budget)
|
||||
if isinstance(include, bool):
|
||||
normalized["includeThoughts"] = include
|
||||
if isinstance(level, str) and level.strip():
|
||||
normalized["thinkingLevel"] = level.strip().lower()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def build_gemini_request(
|
||||
*,
|
||||
messages: List[Dict[str, Any]],
|
||||
tools: Any = None,
|
||||
tool_choice: Any = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
top_p: Optional[float] = None,
|
||||
stop: Any = None,
|
||||
thinking_config: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
contents, system_instruction = _build_gemini_contents(messages)
|
||||
request: Dict[str, Any] = {"contents": contents}
|
||||
if system_instruction:
|
||||
request["systemInstruction"] = system_instruction
|
||||
|
||||
gemini_tools = _translate_tools_to_gemini(tools)
|
||||
if gemini_tools:
|
||||
request["tools"] = gemini_tools
|
||||
|
||||
tool_config = _translate_tool_choice_to_gemini(tool_choice)
|
||||
if tool_config:
|
||||
request["toolConfig"] = tool_config
|
||||
|
||||
generation_config: Dict[str, Any] = {}
|
||||
if temperature is not None:
|
||||
generation_config["temperature"] = temperature
|
||||
if max_tokens is not None:
|
||||
generation_config["maxOutputTokens"] = max_tokens
|
||||
if top_p is not None:
|
||||
generation_config["topP"] = top_p
|
||||
if stop:
|
||||
generation_config["stopSequences"] = stop if isinstance(stop, list) else [str(stop)]
|
||||
normalized_thinking = _normalize_thinking_config(thinking_config)
|
||||
if normalized_thinking:
|
||||
generation_config["thinkingConfig"] = normalized_thinking
|
||||
if generation_config:
|
||||
request["generationConfig"] = generation_config
|
||||
|
||||
return request
|
||||
|
||||
|
||||
def _map_gemini_finish_reason(reason: str) -> str:
|
||||
mapping = {
|
||||
"STOP": "stop",
|
||||
"MAX_TOKENS": "length",
|
||||
"SAFETY": "content_filter",
|
||||
"RECITATION": "content_filter",
|
||||
"OTHER": "stop",
|
||||
}
|
||||
return mapping.get(str(reason or "").upper(), "stop")
|
||||
|
||||
|
||||
def _tool_call_extra_from_part(part: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
sig = part.get("thoughtSignature")
|
||||
if isinstance(sig, str) and sig:
|
||||
return {"google": {"thought_signature": sig}}
|
||||
return None
|
||||
|
||||
|
||||
def _empty_response(model: str) -> SimpleNamespace:
|
||||
message = SimpleNamespace(
|
||||
role="assistant",
|
||||
content="",
|
||||
tool_calls=None,
|
||||
reasoning=None,
|
||||
reasoning_content=None,
|
||||
reasoning_details=None,
|
||||
)
|
||||
choice = SimpleNamespace(index=0, message=message, finish_reason="stop")
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
total_tokens=0,
|
||||
prompt_tokens_details=SimpleNamespace(cached_tokens=0),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
||||
object="chat.completion",
|
||||
created=int(time.time()),
|
||||
model=model,
|
||||
choices=[choice],
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespace:
|
||||
candidates = resp.get("candidates") or []
|
||||
if not isinstance(candidates, list) or not candidates:
|
||||
return _empty_response(model)
|
||||
|
||||
cand = candidates[0] if isinstance(candidates[0], dict) else {}
|
||||
content_obj = cand.get("content") if isinstance(cand, dict) else {}
|
||||
parts = content_obj.get("parts") if isinstance(content_obj, dict) else []
|
||||
|
||||
text_pieces: List[str] = []
|
||||
reasoning_pieces: List[str] = []
|
||||
tool_calls: List[SimpleNamespace] = []
|
||||
|
||||
for index, part in enumerate(parts or []):
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get("thought") is True and isinstance(part.get("text"), str):
|
||||
reasoning_pieces.append(part["text"])
|
||||
continue
|
||||
if isinstance(part.get("text"), str):
|
||||
text_pieces.append(part["text"])
|
||||
continue
|
||||
fc = part.get("functionCall")
|
||||
if isinstance(fc, dict) and fc.get("name"):
|
||||
try:
|
||||
args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
args_str = "{}"
|
||||
tool_call = SimpleNamespace(
|
||||
id=f"call_{uuid.uuid4().hex[:12]}",
|
||||
type="function",
|
||||
index=index,
|
||||
function=SimpleNamespace(name=str(fc["name"]), arguments=args_str),
|
||||
)
|
||||
extra_content = _tool_call_extra_from_part(part)
|
||||
if extra_content:
|
||||
tool_call.extra_content = extra_content
|
||||
tool_calls.append(tool_call)
|
||||
|
||||
finish_reason = "tool_calls" if tool_calls else _map_gemini_finish_reason(str(cand.get("finishReason") or ""))
|
||||
usage_meta = resp.get("usageMetadata") or {}
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=int(usage_meta.get("promptTokenCount") or 0),
|
||||
completion_tokens=int(usage_meta.get("candidatesTokenCount") or 0),
|
||||
total_tokens=int(usage_meta.get("totalTokenCount") or 0),
|
||||
prompt_tokens_details=SimpleNamespace(
|
||||
cached_tokens=int(usage_meta.get("cachedContentTokenCount") or 0),
|
||||
),
|
||||
)
|
||||
reasoning = "".join(reasoning_pieces) or None
|
||||
message = SimpleNamespace(
|
||||
role="assistant",
|
||||
content="".join(text_pieces) if text_pieces else None,
|
||||
tool_calls=tool_calls or None,
|
||||
reasoning=reasoning,
|
||||
reasoning_content=reasoning,
|
||||
reasoning_details=None,
|
||||
)
|
||||
choice = SimpleNamespace(index=0, message=message, finish_reason=finish_reason)
|
||||
return SimpleNamespace(
|
||||
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
||||
object="chat.completion",
|
||||
created=int(time.time()),
|
||||
model=model,
|
||||
choices=[choice],
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
class _GeminiStreamChunk(SimpleNamespace):
|
||||
pass
|
||||
|
||||
|
||||
def _make_stream_chunk(
|
||||
*,
|
||||
model: str,
|
||||
content: str = "",
|
||||
tool_call_delta: Optional[Dict[str, Any]] = None,
|
||||
finish_reason: Optional[str] = None,
|
||||
reasoning: str = "",
|
||||
) -> _GeminiStreamChunk:
|
||||
delta_kwargs: Dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": None,
|
||||
"reasoning": None,
|
||||
"reasoning_content": None,
|
||||
}
|
||||
if content:
|
||||
delta_kwargs["content"] = content
|
||||
if tool_call_delta is not None:
|
||||
tool_delta = SimpleNamespace(
|
||||
index=tool_call_delta.get("index", 0),
|
||||
id=tool_call_delta.get("id") or f"call_{uuid.uuid4().hex[:12]}",
|
||||
type="function",
|
||||
function=SimpleNamespace(
|
||||
name=tool_call_delta.get("name") or "",
|
||||
arguments=tool_call_delta.get("arguments") or "",
|
||||
),
|
||||
)
|
||||
extra_content = tool_call_delta.get("extra_content")
|
||||
if isinstance(extra_content, dict):
|
||||
tool_delta.extra_content = extra_content
|
||||
delta_kwargs["tool_calls"] = [tool_delta]
|
||||
if reasoning:
|
||||
delta_kwargs["reasoning"] = reasoning
|
||||
delta_kwargs["reasoning_content"] = reasoning
|
||||
delta = SimpleNamespace(**delta_kwargs)
|
||||
choice = SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason)
|
||||
return _GeminiStreamChunk(
|
||||
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
||||
object="chat.completion.chunk",
|
||||
created=int(time.time()),
|
||||
model=model,
|
||||
choices=[choice],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
|
||||
def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]:
|
||||
buffer = ""
|
||||
for chunk in response.iter_text():
|
||||
if not chunk:
|
||||
continue
|
||||
buffer += chunk
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
line = line.rstrip("\r")
|
||||
if not line:
|
||||
continue
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
data = line[6:]
|
||||
if data == "[DONE]":
|
||||
return
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Non-JSON Gemini SSE line: %s", data[:200])
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
yield payload
|
||||
|
||||
|
||||
def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: Dict[str, Dict[str, Any]]) -> List[_GeminiStreamChunk]:
|
||||
candidates = event.get("candidates") or []
|
||||
if not candidates:
|
||||
return []
|
||||
cand = candidates[0] if isinstance(candidates[0], dict) else {}
|
||||
parts = ((cand.get("content") or {}).get("parts") or []) if isinstance(cand, dict) else []
|
||||
chunks: List[_GeminiStreamChunk] = []
|
||||
|
||||
for part_index, part in enumerate(parts):
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get("thought") is True and isinstance(part.get("text"), str):
|
||||
chunks.append(_make_stream_chunk(model=model, reasoning=part["text"]))
|
||||
continue
|
||||
if isinstance(part.get("text"), str) and part["text"]:
|
||||
chunks.append(_make_stream_chunk(model=model, content=part["text"]))
|
||||
fc = part.get("functionCall")
|
||||
if isinstance(fc, dict) and fc.get("name"):
|
||||
name = str(fc["name"])
|
||||
try:
|
||||
args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False, sort_keys=True)
|
||||
except (TypeError, ValueError):
|
||||
args_str = "{}"
|
||||
thought_signature = part.get("thoughtSignature") if isinstance(part.get("thoughtSignature"), str) else ""
|
||||
call_key = json.dumps(
|
||||
{
|
||||
"part_index": part_index,
|
||||
"name": name,
|
||||
"thought_signature": thought_signature,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
slot = tool_call_indices.get(call_key)
|
||||
if slot is None:
|
||||
slot = {
|
||||
"index": len(tool_call_indices),
|
||||
"id": f"call_{uuid.uuid4().hex[:12]}",
|
||||
"last_arguments": "",
|
||||
}
|
||||
tool_call_indices[call_key] = slot
|
||||
emitted_arguments = args_str
|
||||
last_arguments = str(slot.get("last_arguments") or "")
|
||||
if last_arguments:
|
||||
if args_str == last_arguments:
|
||||
emitted_arguments = ""
|
||||
elif args_str.startswith(last_arguments):
|
||||
emitted_arguments = args_str[len(last_arguments):]
|
||||
slot["last_arguments"] = args_str
|
||||
chunks.append(
|
||||
_make_stream_chunk(
|
||||
model=model,
|
||||
tool_call_delta={
|
||||
"index": slot["index"],
|
||||
"id": slot["id"],
|
||||
"name": name,
|
||||
"arguments": emitted_arguments,
|
||||
"extra_content": _tool_call_extra_from_part(part),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
finish_reason_raw = str(cand.get("finishReason") or "")
|
||||
if finish_reason_raw:
|
||||
mapped = "tool_calls" if tool_call_indices else _map_gemini_finish_reason(finish_reason_raw)
|
||||
chunks.append(_make_stream_chunk(model=model, finish_reason=mapped))
|
||||
return chunks
|
||||
|
||||
|
||||
def gemini_http_error(response: httpx.Response) -> GeminiAPIError:
|
||||
status = response.status_code
|
||||
body_text = ""
|
||||
body_json: Dict[str, Any] = {}
|
||||
try:
|
||||
body_text = response.text
|
||||
except Exception:
|
||||
body_text = ""
|
||||
if body_text:
|
||||
try:
|
||||
parsed = json.loads(body_text)
|
||||
if isinstance(parsed, dict):
|
||||
body_json = parsed
|
||||
except (ValueError, TypeError):
|
||||
body_json = {}
|
||||
|
||||
err_obj = body_json.get("error") if isinstance(body_json, dict) else None
|
||||
if not isinstance(err_obj, dict):
|
||||
err_obj = {}
|
||||
err_status = str(err_obj.get("status") or "").strip()
|
||||
err_message = str(err_obj.get("message") or "").strip()
|
||||
details_list = err_obj.get("details") if isinstance(err_obj.get("details"), list) else []
|
||||
|
||||
reason = ""
|
||||
retry_after: Optional[float] = None
|
||||
metadata: Dict[str, Any] = {}
|
||||
for detail in details_list:
|
||||
if not isinstance(detail, dict):
|
||||
continue
|
||||
type_url = str(detail.get("@type") or "")
|
||||
if not reason and type_url.endswith("/google.rpc.ErrorInfo"):
|
||||
reason_value = detail.get("reason")
|
||||
if isinstance(reason_value, str):
|
||||
reason = reason_value
|
||||
md = detail.get("metadata")
|
||||
if isinstance(md, dict):
|
||||
metadata = md
|
||||
header_retry = response.headers.get("Retry-After") or response.headers.get("retry-after")
|
||||
if header_retry:
|
||||
try:
|
||||
retry_after = float(header_retry)
|
||||
except (TypeError, ValueError):
|
||||
retry_after = None
|
||||
|
||||
code = f"gemini_http_{status}"
|
||||
if status == 401:
|
||||
code = "gemini_unauthorized"
|
||||
elif status == 429:
|
||||
code = "gemini_rate_limited"
|
||||
elif status == 404:
|
||||
code = "gemini_model_not_found"
|
||||
|
||||
if err_message:
|
||||
message = f"Gemini HTTP {status} ({err_status or 'error'}): {err_message}"
|
||||
else:
|
||||
message = f"Gemini returned HTTP {status}: {body_text[:500]}"
|
||||
|
||||
return GeminiAPIError(
|
||||
message,
|
||||
code=code,
|
||||
status_code=status,
|
||||
response=response,
|
||||
retry_after=retry_after,
|
||||
details={
|
||||
"status": err_status,
|
||||
"reason": reason,
|
||||
"metadata": metadata,
|
||||
"message": err_message,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class _GeminiChatCompletions:
|
||||
def __init__(self, client: "GeminiNativeClient"):
|
||||
self._client = client
|
||||
|
||||
def create(self, **kwargs: Any) -> Any:
|
||||
return self._client._create_chat_completion(**kwargs)
|
||||
|
||||
|
||||
class _AsyncGeminiChatCompletions:
|
||||
def __init__(self, client: "AsyncGeminiNativeClient"):
|
||||
self._client = client
|
||||
|
||||
async def create(self, **kwargs: Any) -> Any:
|
||||
return await self._client._create_chat_completion(**kwargs)
|
||||
|
||||
|
||||
class _GeminiChatNamespace:
|
||||
def __init__(self, client: "GeminiNativeClient"):
|
||||
self.completions = _GeminiChatCompletions(client)
|
||||
|
||||
|
||||
class _AsyncGeminiChatNamespace:
|
||||
def __init__(self, client: "AsyncGeminiNativeClient"):
|
||||
self.completions = _AsyncGeminiChatCompletions(client)
|
||||
|
||||
|
||||
class GeminiNativeClient:
|
||||
"""Minimal OpenAI-SDK-compatible facade over Gemini's native REST API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
base_url: Optional[str] = None,
|
||||
default_headers: Optional[Dict[str, str]] = None,
|
||||
timeout: Any = None,
|
||||
http_client: Optional[httpx.Client] = None,
|
||||
**_: Any,
|
||||
) -> None:
|
||||
self.api_key = api_key
|
||||
normalized_base = (base_url or DEFAULT_GEMINI_BASE_URL).rstrip("/")
|
||||
if normalized_base.endswith("/openai"):
|
||||
normalized_base = normalized_base[: -len("/openai")]
|
||||
self.base_url = normalized_base
|
||||
self._default_headers = dict(default_headers or {})
|
||||
self.chat = _GeminiChatNamespace(self)
|
||||
self.is_closed = False
|
||||
self._http = http_client or httpx.Client(
|
||||
timeout=timeout or httpx.Timeout(connect=15.0, read=600.0, write=30.0, pool=30.0)
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.is_closed = True
|
||||
try:
|
||||
self._http.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"x-goog-api-key": self.api_key,
|
||||
"User-Agent": "hermes-agent (gemini-native)",
|
||||
}
|
||||
headers.update(self._default_headers)
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def _advance_stream_iterator(iterator: Iterator[_GeminiStreamChunk]) -> tuple[bool, Optional[_GeminiStreamChunk]]:
|
||||
try:
|
||||
return False, next(iterator)
|
||||
except StopIteration:
|
||||
return True, None
|
||||
|
||||
def _create_chat_completion(
|
||||
self,
|
||||
*,
|
||||
model: str = "gemini-2.5-flash",
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
stream: bool = False,
|
||||
tools: Any = None,
|
||||
tool_choice: Any = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
top_p: Optional[float] = None,
|
||||
stop: Any = None,
|
||||
extra_body: Optional[Dict[str, Any]] = None,
|
||||
timeout: Any = None,
|
||||
**_: Any,
|
||||
) -> Any:
|
||||
thinking_config = None
|
||||
if isinstance(extra_body, dict):
|
||||
thinking_config = extra_body.get("thinking_config") or extra_body.get("thinkingConfig")
|
||||
|
||||
request = build_gemini_request(
|
||||
messages=messages or [],
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
stop=stop,
|
||||
thinking_config=thinking_config,
|
||||
)
|
||||
|
||||
if stream:
|
||||
return self._stream_completion(model=model, request=request, timeout=timeout)
|
||||
|
||||
url = f"{self.base_url}/models/{model}:generateContent"
|
||||
response = self._http.post(url, json=request, headers=self._headers(), timeout=timeout)
|
||||
if response.status_code != 200:
|
||||
raise gemini_http_error(response)
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise GeminiAPIError(
|
||||
f"Invalid JSON from Gemini native API: {exc}",
|
||||
code="gemini_invalid_json",
|
||||
status_code=response.status_code,
|
||||
response=response,
|
||||
) from exc
|
||||
return translate_gemini_response(payload, model=model)
|
||||
|
||||
def _stream_completion(self, *, model: str, request: Dict[str, Any], timeout: Any = None) -> Iterator[_GeminiStreamChunk]:
|
||||
url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse"
|
||||
stream_headers = dict(self._headers())
|
||||
stream_headers["Accept"] = "text/event-stream"
|
||||
|
||||
def _generator() -> Iterator[_GeminiStreamChunk]:
|
||||
try:
|
||||
with self._http.stream("POST", url, json=request, headers=stream_headers, timeout=timeout) as response:
|
||||
if response.status_code != 200:
|
||||
response.read()
|
||||
raise gemini_http_error(response)
|
||||
tool_call_indices: Dict[str, Dict[str, Any]] = {}
|
||||
for event in _iter_sse_events(response):
|
||||
for chunk in translate_stream_event(event, model, tool_call_indices):
|
||||
yield chunk
|
||||
except httpx.HTTPError as exc:
|
||||
raise GeminiAPIError(
|
||||
f"Gemini streaming request failed: {exc}",
|
||||
code="gemini_stream_error",
|
||||
) from exc
|
||||
|
||||
return _generator()
|
||||
|
||||
|
||||
class AsyncGeminiNativeClient:
|
||||
"""Async wrapper used by auxiliary_client for native Gemini calls."""
|
||||
|
||||
def __init__(self, sync_client: GeminiNativeClient):
|
||||
self._sync = sync_client
|
||||
self.api_key = sync_client.api_key
|
||||
self.base_url = sync_client.base_url
|
||||
self.chat = _AsyncGeminiChatNamespace(self)
|
||||
|
||||
async def _create_chat_completion(self, **kwargs: Any) -> Any:
|
||||
stream = bool(kwargs.get("stream"))
|
||||
result = await asyncio.to_thread(self._sync.chat.completions.create, **kwargs)
|
||||
if not stream:
|
||||
return result
|
||||
|
||||
async def _async_stream() -> Any:
|
||||
while True:
|
||||
done, chunk = await asyncio.to_thread(self._sync._advance_stream_iterator, result)
|
||||
if done:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
return _async_stream()
|
||||
|
||||
async def close(self) -> None:
|
||||
await asyncio.to_thread(self._sync.close)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Helpers for translating OpenAI-style tool schemas to Gemini's schema subset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# Gemini's ``FunctionDeclaration.parameters`` field accepts the ``Schema``
|
||||
# object, which is only a subset of OpenAPI 3.0 / JSON Schema. Strip fields
|
||||
# outside that subset before sending Hermes tool schemas to Google.
|
||||
_GEMINI_SCHEMA_ALLOWED_KEYS = {
|
||||
"type",
|
||||
"format",
|
||||
"title",
|
||||
"description",
|
||||
"nullable",
|
||||
"enum",
|
||||
"maxItems",
|
||||
"minItems",
|
||||
"properties",
|
||||
"required",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
"example",
|
||||
"anyOf",
|
||||
"propertyOrdering",
|
||||
"default",
|
||||
"items",
|
||||
"minimum",
|
||||
"maximum",
|
||||
}
|
||||
|
||||
|
||||
def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
|
||||
"""Return a Gemini-compatible copy of a tool parameter schema.
|
||||
|
||||
Hermes tool schemas are OpenAI-flavored JSON Schema and may contain keys
|
||||
such as ``$schema`` or ``additionalProperties`` that Google's Gemini
|
||||
``Schema`` object rejects. This helper preserves the documented Gemini
|
||||
subset and recursively sanitizes nested ``properties`` / ``items`` /
|
||||
``anyOf`` definitions.
|
||||
"""
|
||||
|
||||
if not isinstance(schema, dict):
|
||||
return {}
|
||||
|
||||
cleaned: Dict[str, Any] = {}
|
||||
for key, value in schema.items():
|
||||
if key not in _GEMINI_SCHEMA_ALLOWED_KEYS:
|
||||
continue
|
||||
if key == "properties":
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
props: Dict[str, Any] = {}
|
||||
for prop_name, prop_schema in value.items():
|
||||
if not isinstance(prop_name, str):
|
||||
continue
|
||||
props[prop_name] = sanitize_gemini_schema(prop_schema)
|
||||
cleaned[key] = props
|
||||
continue
|
||||
if key == "items":
|
||||
cleaned[key] = sanitize_gemini_schema(value)
|
||||
continue
|
||||
if key == "anyOf":
|
||||
if not isinstance(value, list):
|
||||
continue
|
||||
cleaned[key] = [
|
||||
sanitize_gemini_schema(item)
|
||||
for item in value
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
continue
|
||||
cleaned[key] = value
|
||||
return cleaned
|
||||
|
||||
|
||||
def sanitize_gemini_tool_parameters(parameters: Any) -> Dict[str, Any]:
|
||||
"""Normalize tool parameters to a valid Gemini object schema."""
|
||||
|
||||
cleaned = sanitize_gemini_schema(parameters)
|
||||
if not cleaned:
|
||||
return {"type": "object", "properties": {}}
|
||||
return cleaned
|
||||
@@ -176,66 +176,6 @@ SKILLS_GUIDANCE = (
|
||||
"Skills that aren't maintained become liabilities."
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Workspace tool guidance
|
||||
#
|
||||
# Injected when workspace tools are enabled. The assembler
|
||||
# build_workspace_guidance() returns one coherent block that grows with the
|
||||
# tools available. workspace_delete is intentionally not prompted — it's
|
||||
# destructive and should never be a default reach.
|
||||
# =========================================================================
|
||||
|
||||
WORKSPACE_SEARCH_GUIDANCE_CORE = (
|
||||
"You have workspace_search, a BM25 full-text search tool over files "
|
||||
"indexed from configured workspace roots. When the user asks about "
|
||||
"concepts, terms, or content that could plausibly live in the indexed "
|
||||
"codebase or docs, call workspace_search first — it is faster and more "
|
||||
"precise than terminal-based grep/find/cat for retrieval. "
|
||||
"It returns ranked chunks with path:line_start-line_end and a snippet, "
|
||||
"which you can follow up on by reading the file directly if needed. "
|
||||
"Do NOT use workspace_search for file edits, for content you already "
|
||||
"have, or for files you know aren't in the index (e.g. /tmp, build "
|
||||
"artifacts, the user's non-workspace projects)."
|
||||
)
|
||||
|
||||
WORKSPACE_RETRIEVE_GUIDANCE = (
|
||||
"When you know the specific file path and want its full indexed content "
|
||||
"(all chunks), use workspace_retrieve instead of re-searching. Retrieve "
|
||||
"dumps every chunk for that one path in order."
|
||||
)
|
||||
|
||||
WORKSPACE_LIST_GUIDANCE = (
|
||||
"Use workspace_list to see what's actually in the index when you're "
|
||||
"unsure whether a file is indexed. Prefer this over guessing at paths."
|
||||
)
|
||||
|
||||
WORKSPACE_INDEX_GUIDANCE = (
|
||||
"workspace_index rebuilds the full index. It is expensive — only call "
|
||||
"it when the user has just modified indexed files and search results "
|
||||
"look stale. Never call it speculatively at session start."
|
||||
)
|
||||
|
||||
|
||||
def build_workspace_guidance(available_tools: set[str]) -> str | None:
|
||||
"""Assemble workspace guidance based on which tools are enabled.
|
||||
|
||||
Returns None if workspace_search isn't available (nothing to guide).
|
||||
Otherwise returns a single string composed of the core guidance plus
|
||||
one paragraph per additional workspace tool that's present. The output
|
||||
is a newline-joined block ready to be appended to the system prompt.
|
||||
"""
|
||||
if "workspace_search" not in available_tools:
|
||||
return None
|
||||
sections = [WORKSPACE_SEARCH_GUIDANCE_CORE]
|
||||
if "workspace_retrieve" in available_tools:
|
||||
sections.append(WORKSPACE_RETRIEVE_GUIDANCE)
|
||||
if "workspace_list" in available_tools:
|
||||
sections.append(WORKSPACE_LIST_GUIDANCE)
|
||||
if "workspace_index" in available_tools:
|
||||
sections.append(WORKSPACE_INDEX_GUIDANCE)
|
||||
return "\n".join(sections)
|
||||
|
||||
|
||||
TOOL_USE_ENFORCEMENT_GUIDANCE = (
|
||||
"# Tool-use enforcement\n"
|
||||
"You MUST use your tools to take action — do not describe what you would do "
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
"""Helpers for optional cheap-vs-strong model routing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from utils import is_truthy_value
|
||||
|
||||
_COMPLEX_KEYWORDS = {
|
||||
"debug",
|
||||
"debugging",
|
||||
"implement",
|
||||
"implementation",
|
||||
"refactor",
|
||||
"patch",
|
||||
"traceback",
|
||||
"stacktrace",
|
||||
"exception",
|
||||
"error",
|
||||
"analyze",
|
||||
"analysis",
|
||||
"investigate",
|
||||
"architecture",
|
||||
"design",
|
||||
"compare",
|
||||
"benchmark",
|
||||
"optimize",
|
||||
"optimise",
|
||||
"review",
|
||||
"terminal",
|
||||
"shell",
|
||||
"tool",
|
||||
"tools",
|
||||
"pytest",
|
||||
"test",
|
||||
"tests",
|
||||
"plan",
|
||||
"planning",
|
||||
"delegate",
|
||||
"subagent",
|
||||
"cron",
|
||||
"docker",
|
||||
"kubernetes",
|
||||
}
|
||||
|
||||
_URL_RE = re.compile(r"https?://|www\.", re.IGNORECASE)
|
||||
|
||||
|
||||
def _coerce_bool(value: Any, default: bool = False) -> bool:
|
||||
return is_truthy_value(value, default=default)
|
||||
|
||||
|
||||
def _coerce_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def choose_cheap_model_route(user_message: str, routing_config: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""Return the configured cheap-model route when a message looks simple.
|
||||
|
||||
Conservative by design: if the message has signs of code/tool/debugging/
|
||||
long-form work, keep the primary model.
|
||||
"""
|
||||
cfg = routing_config or {}
|
||||
if not _coerce_bool(cfg.get("enabled"), False):
|
||||
return None
|
||||
|
||||
cheap_model = cfg.get("cheap_model") or {}
|
||||
if not isinstance(cheap_model, dict):
|
||||
return None
|
||||
provider = str(cheap_model.get("provider") or "").strip().lower()
|
||||
model = str(cheap_model.get("model") or "").strip()
|
||||
if not provider or not model:
|
||||
return None
|
||||
|
||||
text = (user_message or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
max_chars = _coerce_int(cfg.get("max_simple_chars"), 160)
|
||||
max_words = _coerce_int(cfg.get("max_simple_words"), 28)
|
||||
|
||||
if len(text) > max_chars:
|
||||
return None
|
||||
if len(text.split()) > max_words:
|
||||
return None
|
||||
if text.count("\n") > 1:
|
||||
return None
|
||||
if "```" in text or "`" in text:
|
||||
return None
|
||||
if _URL_RE.search(text):
|
||||
return None
|
||||
|
||||
lowered = text.lower()
|
||||
words = {token.strip(".,:;!?()[]{}\"'`") for token in lowered.split()}
|
||||
if words & _COMPLEX_KEYWORDS:
|
||||
return None
|
||||
|
||||
route = dict(cheap_model)
|
||||
route["provider"] = provider
|
||||
route["model"] = model
|
||||
route["routing_reason"] = "simple_turn"
|
||||
return route
|
||||
|
||||
|
||||
def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any]], primary: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Resolve the effective model/runtime for one turn.
|
||||
|
||||
Returns a dict with model/runtime/signature/label fields.
|
||||
"""
|
||||
route = choose_cheap_model_route(user_message, routing_config)
|
||||
if not route:
|
||||
return {
|
||||
"model": primary.get("model"),
|
||||
"runtime": {
|
||||
"api_key": primary.get("api_key"),
|
||||
"base_url": primary.get("base_url"),
|
||||
"provider": primary.get("provider"),
|
||||
"api_mode": primary.get("api_mode"),
|
||||
"command": primary.get("command"),
|
||||
"args": list(primary.get("args") or []),
|
||||
"credential_pool": primary.get("credential_pool"),
|
||||
},
|
||||
"label": None,
|
||||
"signature": (
|
||||
primary.get("model"),
|
||||
primary.get("provider"),
|
||||
primary.get("base_url"),
|
||||
primary.get("api_mode"),
|
||||
primary.get("command"),
|
||||
tuple(primary.get("args") or ()),
|
||||
),
|
||||
}
|
||||
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
explicit_api_key = None
|
||||
api_key_env = str(route.get("api_key_env") or "").strip()
|
||||
if api_key_env:
|
||||
explicit_api_key = os.getenv(api_key_env) or None
|
||||
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=route.get("provider"),
|
||||
explicit_api_key=explicit_api_key,
|
||||
explicit_base_url=route.get("base_url"),
|
||||
)
|
||||
except Exception:
|
||||
return {
|
||||
"model": primary.get("model"),
|
||||
"runtime": {
|
||||
"api_key": primary.get("api_key"),
|
||||
"base_url": primary.get("base_url"),
|
||||
"provider": primary.get("provider"),
|
||||
"api_mode": primary.get("api_mode"),
|
||||
"command": primary.get("command"),
|
||||
"args": list(primary.get("args") or []),
|
||||
"credential_pool": primary.get("credential_pool"),
|
||||
},
|
||||
"label": None,
|
||||
"signature": (
|
||||
primary.get("model"),
|
||||
primary.get("provider"),
|
||||
primary.get("base_url"),
|
||||
primary.get("api_mode"),
|
||||
primary.get("command"),
|
||||
tuple(primary.get("args") or ()),
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"model": route.get("model"),
|
||||
"runtime": {
|
||||
"api_key": runtime.get("api_key"),
|
||||
"base_url": runtime.get("base_url"),
|
||||
"provider": runtime.get("provider"),
|
||||
"api_mode": runtime.get("api_mode"),
|
||||
"command": runtime.get("command"),
|
||||
"args": list(runtime.get("args") or []),
|
||||
"credential_pool": runtime.get("credential_pool"),
|
||||
},
|
||||
"label": f"smart route → {route.get('model')} ({runtime.get('provider')})",
|
||||
"signature": (
|
||||
route.get("model"),
|
||||
runtime.get("provider"),
|
||||
runtime.get("base_url"),
|
||||
runtime.get("api_mode"),
|
||||
runtime.get("command"),
|
||||
tuple(runtime.get("args") or ()),
|
||||
),
|
||||
}
|
||||
+23
-17
@@ -66,15 +66,18 @@ model:
|
||||
# max_tokens: 8192
|
||||
|
||||
# Named provider overrides (optional)
|
||||
# Use this for per-provider request timeouts and per-model exceptions.
|
||||
# Use this for per-provider request timeouts, non-stream stale timeouts,
|
||||
# and per-model exceptions.
|
||||
# Applies to the primary turn client on every api_mode (OpenAI-wire, native
|
||||
# Anthropic, and Anthropic-compatible providers), the fallback chain, and
|
||||
# client rebuilds during credential rotation. For OpenAI-wire chat
|
||||
# completions (streaming and non-streaming) the configured value is also
|
||||
# used as the per-request ``timeout=`` kwarg so it wins over the legacy
|
||||
# HERMES_API_TIMEOUT env var (which still applies when no config is set).
|
||||
# Leaving these unset keeps the legacy defaults (HERMES_API_TIMEOUT=1800s,
|
||||
# native Anthropic 900s).
|
||||
# ``stale_timeout_seconds`` controls the non-streaming stale-call detector and
|
||||
# wins over the legacy HERMES_API_CALL_STALE_TIMEOUT env var. Leaving these
|
||||
# unset keeps the legacy defaults (HERMES_API_TIMEOUT=1800s,
|
||||
# HERMES_API_CALL_STALE_TIMEOUT=300s, native Anthropic 900s).
|
||||
#
|
||||
# Not currently wired for AWS Bedrock (bedrock_converse + AnthropicBedrock
|
||||
# SDK paths) — those use boto3 with its own timeout configuration.
|
||||
@@ -82,11 +85,16 @@ model:
|
||||
# providers:
|
||||
# ollama-local:
|
||||
# request_timeout_seconds: 300 # Longer timeout for local cold-starts
|
||||
# stale_timeout_seconds: 900 # Explicitly re-enable stale detection on local endpoints
|
||||
# anthropic:
|
||||
# request_timeout_seconds: 30 # Fast-fail cloud requests
|
||||
# models:
|
||||
# claude-opus-4.6:
|
||||
# timeout_seconds: 600 # Longer timeout for extended-thinking Opus calls
|
||||
# openai-codex:
|
||||
# models:
|
||||
# gpt-5.4:
|
||||
# stale_timeout_seconds: 1800 # Longer non-stream stale timeout for slow large-context turns
|
||||
|
||||
# =============================================================================
|
||||
# OpenRouter Provider Routing (only applies when using OpenRouter)
|
||||
@@ -114,20 +122,6 @@ model:
|
||||
# # Data policy: "allow" (default) or "deny" to exclude providers that may store data
|
||||
# # data_collection: "deny"
|
||||
|
||||
# =============================================================================
|
||||
# Smart Model Routing (optional)
|
||||
# =============================================================================
|
||||
# Use a cheaper model for short/simple turns while keeping your main model for
|
||||
# more complex requests. Disabled by default.
|
||||
#
|
||||
# smart_model_routing:
|
||||
# enabled: true
|
||||
# max_simple_chars: 160
|
||||
# max_simple_words: 28
|
||||
# cheap_model:
|
||||
# provider: openrouter
|
||||
# model: google/gemini-2.5-flash
|
||||
|
||||
# =============================================================================
|
||||
# Git Worktree Isolation
|
||||
# =============================================================================
|
||||
@@ -380,6 +374,18 @@ compression:
|
||||
# web_extract:
|
||||
# provider: "auto"
|
||||
# model: ""
|
||||
#
|
||||
# # Session search — summarizes matching past sessions
|
||||
# session_search:
|
||||
# provider: "auto"
|
||||
# model: ""
|
||||
# timeout: 30
|
||||
# max_concurrency: 3 # Limit parallel summaries to reduce request-burst 429s
|
||||
# extra_body: {} # Provider-specific OpenAI-compatible request fields
|
||||
# # Example for providers that support request-body
|
||||
# # reasoning controls:
|
||||
# # extra_body:
|
||||
# # enable_thinking: false
|
||||
|
||||
# =============================================================================
|
||||
# Persistent Memory
|
||||
|
||||
@@ -310,12 +310,6 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
"enabled": True, # Auto-compress when approaching context limit
|
||||
"threshold": 0.50, # Compress at 50% of model's context limit
|
||||
},
|
||||
"smart_model_routing": {
|
||||
"enabled": False,
|
||||
"max_simple_chars": 160,
|
||||
"max_simple_words": 28,
|
||||
"cheap_model": {},
|
||||
},
|
||||
"agent": {
|
||||
"max_turns": 90, # Default max tool-calling iterations (shared with subagents)
|
||||
"verbose": False,
|
||||
@@ -1147,6 +1141,43 @@ def _rich_text_from_ansi(text: str) -> _RichText:
|
||||
return _RichText.from_ansi(text or "")
|
||||
|
||||
|
||||
def _strip_markdown_syntax(text: str) -> str:
|
||||
"""Best-effort markdown marker removal for plain-text display."""
|
||||
import re
|
||||
|
||||
plain = _rich_text_from_ansi(text or "").plain
|
||||
plain = re.sub(r"^\s{0,3}(?:[-*_]\s*){3,}$", "", plain, flags=re.MULTILINE)
|
||||
plain = re.sub(r"^\s{0,3}#{1,6}\s+", "", plain, flags=re.MULTILINE)
|
||||
# Preserve blockquotes, lists, and checkboxes because they carry structure.
|
||||
plain = re.sub(r"(```+|~~~+)", "", plain)
|
||||
plain = re.sub(r"`([^`]*)`", r"\1", plain)
|
||||
plain = re.sub(r"!\[([^\]]*)\]\([^\)]*\)", r"\1", plain)
|
||||
plain = re.sub(r"\[([^\]]+)\]\([^\)]*\)", r"\1", plain)
|
||||
plain = re.sub(r"\*\*\*([^*]+)\*\*\*", r"\1", plain)
|
||||
plain = re.sub(r"___([^_]+)___", r"\1", plain)
|
||||
plain = re.sub(r"\*\*([^*]+)\*\*", r"\1", plain)
|
||||
plain = re.sub(r"__([^_]+)__", r"\1", plain)
|
||||
plain = re.sub(r"\*([^*]+)\*", r"\1", plain)
|
||||
plain = re.sub(r"_([^_]+)_", r"\1", plain)
|
||||
plain = re.sub(r"~~([^~]+)~~", r"\1", plain)
|
||||
plain = re.sub(r"\n{3,}", "\n\n", plain)
|
||||
return plain.strip("\n")
|
||||
|
||||
|
||||
def _render_final_assistant_content(text: str, mode: str = "render"):
|
||||
"""Render final assistant content as markdown, stripped text, or raw text."""
|
||||
from rich.markdown import Markdown
|
||||
|
||||
normalized_mode = str(mode or "render").strip().lower()
|
||||
if normalized_mode == "strip":
|
||||
return _RichText(_strip_markdown_syntax(text))
|
||||
if normalized_mode == "raw":
|
||||
return _rich_text_from_ansi(text or "")
|
||||
|
||||
plain = _rich_text_from_ansi(text or "").plain
|
||||
return Markdown(plain)
|
||||
|
||||
|
||||
def _cprint(text: str):
|
||||
"""Print ANSI-colored text through prompt_toolkit's native renderer.
|
||||
|
||||
@@ -1724,10 +1755,30 @@ class HermesCLI:
|
||||
|
||||
# streaming: stream tokens to the terminal as they arrive (display.streaming in config.yaml)
|
||||
self.streaming_enabled = CLI_CONFIG["display"].get("streaming", False)
|
||||
self.final_response_markdown = str(
|
||||
CLI_CONFIG["display"].get("final_response_markdown", "strip")
|
||||
).strip().lower() or "strip"
|
||||
if self.final_response_markdown not in {"render", "strip", "raw"}:
|
||||
self.final_response_markdown = "strip"
|
||||
|
||||
# Inline diff previews for write actions (display.inline_diffs in config.yaml)
|
||||
self._inline_diffs_enabled = CLI_CONFIG["display"].get("inline_diffs", True)
|
||||
|
||||
# Submitted multiline user-message preview (display.user_message_preview in config.yaml)
|
||||
_ump = CLI_CONFIG["display"].get("user_message_preview", {})
|
||||
if not isinstance(_ump, dict):
|
||||
_ump = {}
|
||||
try:
|
||||
_ump_first_lines = int(_ump.get("first_lines", 2))
|
||||
except (TypeError, ValueError):
|
||||
_ump_first_lines = 2
|
||||
try:
|
||||
_ump_last_lines = int(_ump.get("last_lines", 2))
|
||||
except (TypeError, ValueError):
|
||||
_ump_last_lines = 2
|
||||
self.user_message_preview_first_lines = max(1, _ump_first_lines)
|
||||
self.user_message_preview_last_lines = max(0, _ump_last_lines)
|
||||
|
||||
# Streaming display state
|
||||
self._stream_buf = "" # Partial line buffer for line-buffered rendering
|
||||
self._stream_started = False # True once first delta arrives
|
||||
@@ -1857,8 +1908,9 @@ class HermesCLI:
|
||||
fb = [fb] if fb.get("provider") and fb.get("model") else []
|
||||
self._fallback_model = fb
|
||||
|
||||
# Optional cheap-vs-strong routing for simple turns
|
||||
self._smart_model_routing = CLI_CONFIG.get("smart_model_routing", {}) or {}
|
||||
# Signature of the currently-initialised agent's runtime. Used to
|
||||
# rebuild the agent when provider / model / base_url changes across
|
||||
# turns (e.g. after /model or credential rotation).
|
||||
self._active_agent_route_signature = None
|
||||
|
||||
# Agent will be initialized on first use
|
||||
@@ -1869,6 +1921,10 @@ class HermesCLI:
|
||||
self.conversation_history: List[Dict[str, Any]] = []
|
||||
self.session_start = datetime.now()
|
||||
self._resumed = False
|
||||
# Per-prompt elapsed timer — started at the beginning of each chat turn,
|
||||
# frozen when the agent thread completes, displayed in the status bar.
|
||||
self._prompt_start_time: Optional[float] = None # time.time() when turn started
|
||||
self._prompt_duration: float = 0.0 # frozen duration of last completed turn
|
||||
# Initialize SQLite session store early so /title works before first message
|
||||
self._session_db = None
|
||||
try:
|
||||
@@ -1967,6 +2023,44 @@ class HermesCLI:
|
||||
filled = round((safe_percent / 100) * width)
|
||||
return f"[{('█' * filled) + ('░' * max(0, width - filled))}]"
|
||||
|
||||
@staticmethod
|
||||
def _format_prompt_elapsed(prompt_start_time: Optional[float], prompt_duration: float, live: bool = False) -> str:
|
||||
"""Format per-prompt elapsed time for the status bar.
|
||||
|
||||
Always returns a string — shows 0s on fresh start before first turn.
|
||||
Keeps seconds visible at all scales so it increments smoothly:
|
||||
59s → 1m → 1m 1s → ... → 1m 59s → 2m → 2m 1s → ...
|
||||
59m 59s → 1h → 1h 0m 1s → ...
|
||||
23h 59m 59s → 1d → 1d 0h 1m → ...
|
||||
|
||||
Emoji prefix: ⏱ when turn is live, ⏲ when frozen or fresh start.
|
||||
Uses width-1 (no variation selector) glyphs so the status bar stays
|
||||
aligned in monospace terminals.
|
||||
"""
|
||||
if prompt_start_time is None and prompt_duration == 0.0:
|
||||
return "⏲ 0s"
|
||||
elapsed = time.time() - prompt_start_time if prompt_start_time is not None else prompt_duration
|
||||
elapsed = max(0.0, elapsed)
|
||||
|
||||
days = int(elapsed // 86400)
|
||||
remaining = elapsed % 86400
|
||||
hours = int(remaining // 3600)
|
||||
remaining = remaining % 3600
|
||||
minutes = int(remaining // 60)
|
||||
seconds = int(remaining % 60)
|
||||
|
||||
if days > 0:
|
||||
time_str = f"{days}d {hours}h {minutes}m"
|
||||
elif hours > 0:
|
||||
time_str = f"{hours}h {minutes}m {seconds}s" if seconds else f"{hours}h {minutes}m"
|
||||
elif minutes > 0:
|
||||
time_str = f"{minutes}m {seconds}s" if seconds else f"{minutes}m"
|
||||
else:
|
||||
time_str = f"{int(elapsed)}s"
|
||||
|
||||
emoji = "⏱" if live else "⏲"
|
||||
return f"{emoji} {time_str}"
|
||||
|
||||
def _get_status_bar_snapshot(self) -> Dict[str, Any]:
|
||||
# Prefer the agent's model name — it updates on fallback.
|
||||
# self.model reflects the originally configured model and never
|
||||
@@ -1985,6 +2079,11 @@ class HermesCLI:
|
||||
"model_name": model_name,
|
||||
"model_short": model_short,
|
||||
"duration": format_duration_compact(elapsed_seconds),
|
||||
"prompt_elapsed": self._format_prompt_elapsed(
|
||||
getattr(self, "_prompt_start_time", None),
|
||||
getattr(self, "_prompt_duration", 0.0),
|
||||
live=getattr(self, "_prompt_start_time", None) is not None,
|
||||
),
|
||||
"context_tokens": 0,
|
||||
"context_length": None,
|
||||
"context_percent": None,
|
||||
@@ -2176,6 +2275,9 @@ class HermesCLI:
|
||||
|
||||
parts = [f"⚕ {snapshot['model_short']}", context_label, percent_label]
|
||||
parts.append(duration_label)
|
||||
prompt_elapsed = snapshot.get("prompt_elapsed")
|
||||
if prompt_elapsed:
|
||||
parts.append(prompt_elapsed)
|
||||
return self._trim_status_bar_text(" │ ".join(parts), width)
|
||||
except Exception:
|
||||
return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}"
|
||||
@@ -2234,8 +2336,13 @@ class HermesCLI:
|
||||
(bar_style, percent_label),
|
||||
("class:status-bar-dim", " │ "),
|
||||
("class:status-bar-dim", duration_label),
|
||||
("class:status-bar", " "),
|
||||
]
|
||||
# Position 7: per-prompt elapsed timer (live or frozen)
|
||||
prompt_elapsed = snapshot.get("prompt_elapsed")
|
||||
if prompt_elapsed:
|
||||
frags.append(("class:status-bar-dim", " │ "))
|
||||
frags.append(("class:status-bar-dim", prompt_elapsed))
|
||||
frags.append(("class:status-bar", " "))
|
||||
|
||||
total_width = sum(self._status_bar_display_width(text) for _, text in frags)
|
||||
if total_width > width:
|
||||
@@ -2454,6 +2561,61 @@ class HermesCLI:
|
||||
if flush_text:
|
||||
self._emit_reasoning_preview(flush_text)
|
||||
|
||||
def _format_submitted_user_message_preview(self, user_input: str) -> str:
|
||||
"""Format the submitted user-message scrollback preview."""
|
||||
lines = user_input.split("\n")
|
||||
if len(lines) <= 1:
|
||||
return f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]"
|
||||
|
||||
first_lines = int(getattr(self, "user_message_preview_first_lines", 2))
|
||||
last_lines = int(getattr(self, "user_message_preview_last_lines", 2))
|
||||
first_lines = max(1, first_lines)
|
||||
last_lines = max(0, last_lines)
|
||||
head = lines[:first_lines]
|
||||
remaining_after_head = max(0, len(lines) - len(head))
|
||||
tail_count = min(last_lines, remaining_after_head)
|
||||
tail = lines[-tail_count:] if tail_count else []
|
||||
|
||||
hidden_middle_count = len(lines) - len(head) - len(tail)
|
||||
if hidden_middle_count < 0:
|
||||
hidden_middle_count = 0
|
||||
tail = []
|
||||
|
||||
preview_lines = [
|
||||
f"[bold {_accent_hex()}]●[/] [bold]{_escape(head[0])}[/]"
|
||||
]
|
||||
preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in head[1:])
|
||||
|
||||
if hidden_middle_count > 0:
|
||||
noun = "line" if hidden_middle_count == 1 else "lines"
|
||||
preview_lines.append(f"[dim]... (+{hidden_middle_count} more {noun})[/]")
|
||||
|
||||
preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in tail)
|
||||
return "\n".join(preview_lines)
|
||||
|
||||
def _expand_paste_references(self, text: str | None) -> str:
|
||||
"""Expand [Pasted text #N -> file] placeholders into file contents."""
|
||||
if not isinstance(text, str) or "[Pasted text #" not in text:
|
||||
return text or ""
|
||||
import re as _re
|
||||
|
||||
paste_ref_re = _re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
|
||||
|
||||
def _expand_ref(match):
|
||||
path = Path(match.group(1))
|
||||
return path.read_text(encoding="utf-8") if path.exists() else match.group(0)
|
||||
|
||||
return paste_ref_re.sub(_expand_ref, text)
|
||||
|
||||
def _print_user_message_preview(self, user_input: str) -> None:
|
||||
"""Render a user message using the normal chat scrollback style."""
|
||||
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
||||
text = str(user_input or "")
|
||||
if "\n" in text:
|
||||
ChatConsole().print(self._format_submitted_user_message_preview(text))
|
||||
else:
|
||||
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(text)}[/]")
|
||||
|
||||
def _stream_reasoning_delta(self, text: str) -> None:
|
||||
"""Stream reasoning/thinking tokens into a dim box above the response.
|
||||
|
||||
@@ -2697,6 +2859,8 @@ class HermesCLI:
|
||||
_tc = getattr(self, "_stream_text_ansi", "")
|
||||
while "\n" in self._stream_buf:
|
||||
line, self._stream_buf = self._stream_buf.split("\n", 1)
|
||||
if self.final_response_markdown == "strip":
|
||||
line = _strip_markdown_syntax(line)
|
||||
_cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}")
|
||||
|
||||
def _flush_stream(self) -> None:
|
||||
@@ -2714,7 +2878,8 @@ class HermesCLI:
|
||||
|
||||
if self._stream_buf:
|
||||
_tc = getattr(self, "_stream_text_ansi", "")
|
||||
_cprint(f"{_STREAM_PAD}{_tc}{self._stream_buf}{_RST}" if _tc else f"{_STREAM_PAD}{self._stream_buf}")
|
||||
line = _strip_markdown_syntax(self._stream_buf) if self.final_response_markdown == "strip" else self._stream_buf
|
||||
_cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}")
|
||||
self._stream_buf = ""
|
||||
|
||||
# Close the response box
|
||||
@@ -2776,6 +2941,39 @@ class HermesCLI:
|
||||
self._command_status = ""
|
||||
self._invalidate(min_interval=0.0)
|
||||
|
||||
def _open_external_editor(self, buffer=None) -> bool:
|
||||
"""Open the active input buffer in an external editor."""
|
||||
app = getattr(self, "_app", None)
|
||||
if not app:
|
||||
_cprint(f"{_DIM}External editor is only available inside the interactive CLI.{_RST}")
|
||||
return False
|
||||
if self._command_running:
|
||||
_cprint(f"{_DIM}Wait for the current command to finish before opening the editor.{_RST}")
|
||||
return False
|
||||
if self._sudo_state or self._secret_state or self._approval_state or self._clarify_state:
|
||||
_cprint(f"{_DIM}Finish the active prompt before opening the editor.{_RST}")
|
||||
return False
|
||||
target_buffer = buffer or getattr(app, "current_buffer", None)
|
||||
if target_buffer is None:
|
||||
_cprint(f"{_DIM}No active input buffer is available for the external editor.{_RST}")
|
||||
return False
|
||||
try:
|
||||
existing_text = getattr(target_buffer, "text", "")
|
||||
expanded_text = self._expand_paste_references(existing_text)
|
||||
if expanded_text != existing_text and hasattr(target_buffer, "text"):
|
||||
self._skip_paste_collapse = True
|
||||
target_buffer.text = expanded_text
|
||||
if hasattr(target_buffer, "cursor_position"):
|
||||
target_buffer.cursor_position = len(expanded_text)
|
||||
# Set skip flag (again) so the text-change event fired when the
|
||||
# editor closes does not re-collapse the returned content.
|
||||
self._skip_paste_collapse = True
|
||||
target_buffer.open_in_editor(validate_and_handle=False)
|
||||
return True
|
||||
except Exception as exc:
|
||||
_cprint(f"{_DIM}Failed to open external editor: {exc}{_RST}")
|
||||
return False
|
||||
|
||||
def _ensure_runtime_credentials(self) -> bool:
|
||||
"""
|
||||
Ensure runtime credentials are resolved before agent use.
|
||||
@@ -2883,24 +3081,36 @@ class HermesCLI:
|
||||
return True
|
||||
|
||||
def _resolve_turn_agent_config(self, user_message: str) -> dict:
|
||||
"""Resolve model/runtime overrides for a single user turn."""
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
"""Build the effective model/runtime config for a single user turn.
|
||||
|
||||
Always uses the session's primary model/provider. If the user has
|
||||
toggled `/fast` on and the current model supports Priority
|
||||
Processing / Anthropic fast mode, attach `request_overrides` so the
|
||||
API call is marked accordingly.
|
||||
"""
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
route = resolve_turn_route(
|
||||
user_message,
|
||||
self._smart_model_routing,
|
||||
{
|
||||
"model": self.model,
|
||||
"api_key": self.api_key,
|
||||
"base_url": self.base_url,
|
||||
"provider": self.provider,
|
||||
"api_mode": self.api_mode,
|
||||
"command": self.acp_command,
|
||||
"args": list(self.acp_args or []),
|
||||
"credential_pool": getattr(self, "_credential_pool", None),
|
||||
},
|
||||
)
|
||||
runtime = {
|
||||
"api_key": self.api_key,
|
||||
"base_url": self.base_url,
|
||||
"provider": self.provider,
|
||||
"api_mode": self.api_mode,
|
||||
"command": self.acp_command,
|
||||
"args": list(self.acp_args or []),
|
||||
"credential_pool": getattr(self, "_credential_pool", None),
|
||||
}
|
||||
route = {
|
||||
"model": self.model,
|
||||
"runtime": runtime,
|
||||
"signature": (
|
||||
self.model,
|
||||
runtime["provider"],
|
||||
runtime["base_url"],
|
||||
runtime["api_mode"],
|
||||
runtime["command"],
|
||||
tuple(runtime["args"]),
|
||||
),
|
||||
}
|
||||
|
||||
service_tier = getattr(self, "service_tier", None)
|
||||
if not service_tier:
|
||||
@@ -2908,13 +3118,13 @@ class HermesCLI:
|
||||
return route
|
||||
|
||||
try:
|
||||
overrides = resolve_fast_mode_overrides(route.get("model"))
|
||||
overrides = resolve_fast_mode_overrides(route["model"])
|
||||
except Exception:
|
||||
overrides = None
|
||||
route["request_overrides"] = overrides
|
||||
return route
|
||||
|
||||
def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, route_label: str = None, request_overrides: dict | None = None) -> bool:
|
||||
def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool:
|
||||
"""
|
||||
Initialize the agent on first use.
|
||||
When resuming a session, restores conversation history from SQLite.
|
||||
@@ -3941,6 +4151,7 @@ class HermesCLI:
|
||||
|
||||
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
|
||||
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
|
||||
_cprint(f" {_DIM}Draft editor: Ctrl+G{_RST}")
|
||||
if _is_termux_environment():
|
||||
_cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n")
|
||||
else:
|
||||
@@ -5752,9 +5963,6 @@ class HermesCLI:
|
||||
print(f" {status} {p['name']}{version}{detail}{error}")
|
||||
except Exception as e:
|
||||
print(f"Plugin system error: {e}")
|
||||
elif canonical == "workspace":
|
||||
from hermes_cli.workspace_slash import handle_workspace_slash
|
||||
handle_workspace_slash(cmd_original, console=self.console)
|
||||
elif canonical == "rollback":
|
||||
self._handle_rollback_command(cmd_original)
|
||||
elif canonical == "snapshot":
|
||||
@@ -6043,7 +6251,7 @@ class HermesCLI:
|
||||
|
||||
_chat_console = ChatConsole()
|
||||
_chat_console.print(Panel(
|
||||
_rich_text_from_ansi(response),
|
||||
_render_final_assistant_content(response, mode=self.final_response_markdown),
|
||||
title=f"[{_resp_color} bold]{label} (background #{task_num})[/]",
|
||||
title_align="left",
|
||||
border_style=_resp_color,
|
||||
@@ -6168,7 +6376,7 @@ class HermesCLI:
|
||||
_resp_color = "#4F6D4A"
|
||||
|
||||
ChatConsole().print(Panel(
|
||||
_rich_text_from_ansi(response),
|
||||
_render_final_assistant_content(response, mode=self.final_response_markdown),
|
||||
title=f"[{_resp_color} bold]⚕ /btw[/]",
|
||||
title_align="left",
|
||||
border_style=_resp_color,
|
||||
@@ -6660,6 +6868,18 @@ class HermesCLI:
|
||||
focus_topic=focus_topic or None,
|
||||
)
|
||||
self.conversation_history = compressed
|
||||
# _compress_context ends the old session and creates a new child
|
||||
# session on the agent (run_agent.py::_compress_context). Sync the
|
||||
# CLI's session_id so /status, /resume, exit summary, and title
|
||||
# generation all point at the live continuation session, not the
|
||||
# ended parent. Without this, subsequent end_session() calls target
|
||||
# the already-closed parent and the child is orphaned.
|
||||
if (
|
||||
getattr(self.agent, "session_id", None)
|
||||
and self.agent.session_id != self.session_id
|
||||
):
|
||||
self.session_id = self.agent.session_id
|
||||
self._pending_title = None
|
||||
new_tokens = estimate_messages_tokens_rough(self.conversation_history)
|
||||
summary = summarize_manual_compression(
|
||||
original_history,
|
||||
@@ -7914,7 +8134,6 @@ class HermesCLI:
|
||||
if not self._init_agent(
|
||||
model_override=turn_route["model"],
|
||||
runtime_override=turn_route["runtime"],
|
||||
route_label=turn_route["label"],
|
||||
request_overrides=turn_route.get("request_overrides"),
|
||||
):
|
||||
return None
|
||||
@@ -8072,6 +8291,10 @@ class HermesCLI:
|
||||
# Start agent in background thread (daemon so it cannot keep the
|
||||
# process alive when the user closes the terminal tab — SIGHUP
|
||||
# exits the main thread and daemon threads are reaped automatically).
|
||||
# Start per-prompt elapsed timer — frozen after the agent thread
|
||||
# finishes; reset on the next turn.
|
||||
self._prompt_start_time = time.time()
|
||||
self._prompt_duration = 0.0
|
||||
agent_thread = threading.Thread(target=run_agent, daemon=True)
|
||||
agent_thread.start()
|
||||
|
||||
@@ -8149,6 +8372,12 @@ class HermesCLI:
|
||||
# but guard against edge cases.
|
||||
agent_thread.join(timeout=30)
|
||||
|
||||
# Freeze per-prompt elapsed timer once the agent thread has
|
||||
# exited (or been abandoned as a daemon after interrupt).
|
||||
if self._prompt_start_time is not None:
|
||||
self._prompt_duration = max(0.0, time.time() - self._prompt_start_time)
|
||||
self._prompt_start_time = None
|
||||
|
||||
# Proactively clean up async clients whose event loop is dead.
|
||||
# The agent thread may have created AsyncOpenAI clients bound
|
||||
# to a per-thread event loop; if that loop is now closed, those
|
||||
@@ -8179,6 +8408,20 @@ class HermesCLI:
|
||||
# Update history with full conversation
|
||||
self.conversation_history = result.get("messages", self.conversation_history) if result else self.conversation_history
|
||||
|
||||
# If auto-compression fired mid-turn, the agent created a new
|
||||
# continuation session and mutated self.agent.session_id. Sync
|
||||
# the CLI's session_id so /status, /resume, title generation,
|
||||
# and the exit summary all target the live child session rather
|
||||
# than the ended parent. Mirrors the gateway's post-run sync
|
||||
# (gateway/run.py around line 9983).
|
||||
if (
|
||||
self.agent
|
||||
and getattr(self.agent, "session_id", None)
|
||||
and self.agent.session_id != self.session_id
|
||||
):
|
||||
self.session_id = self.agent.session_id
|
||||
self._pending_title = None
|
||||
|
||||
# Get the final response
|
||||
response = result.get("final_response", "") if result else ""
|
||||
|
||||
@@ -8268,7 +8511,7 @@ class HermesCLI:
|
||||
else:
|
||||
_chat_console = ChatConsole()
|
||||
_chat_console.print(Panel(
|
||||
_rich_text_from_ansi(response),
|
||||
_render_final_assistant_content(response, mode=self.final_response_markdown),
|
||||
title=f"[{_resp_color} bold]{label}[/]",
|
||||
title_align="left",
|
||||
border_style=_resp_color,
|
||||
@@ -8834,6 +9077,16 @@ class HermesCLI:
|
||||
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
|
||||
event.current_buffer.insert_text('\n')
|
||||
|
||||
@kb.add(
|
||||
'c-g',
|
||||
filter=Condition(
|
||||
lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state
|
||||
),
|
||||
)
|
||||
def handle_open_in_editor(event):
|
||||
"""Ctrl+G opens the current draft in an external editor."""
|
||||
cli_ref._open_external_editor(event.current_buffer)
|
||||
|
||||
@kb.add('tab', eager=True)
|
||||
def handle_tab(event):
|
||||
"""Tab: accept completion, auto-suggestion, or start completions.
|
||||
@@ -9285,6 +9538,7 @@ class HermesCLI:
|
||||
_prev_text_len = [0]
|
||||
_prev_newline_count = [0]
|
||||
_paste_just_collapsed = [False]
|
||||
self._skip_paste_collapse = False
|
||||
|
||||
def _on_text_changed(buf):
|
||||
"""Detect large pastes and collapse them to a file reference.
|
||||
@@ -9304,8 +9558,9 @@ class HermesCLI:
|
||||
text = buf.text
|
||||
chars_added = len(text) - _prev_text_len[0]
|
||||
_prev_text_len[0] = len(text)
|
||||
if _paste_just_collapsed[0]:
|
||||
if _paste_just_collapsed[0] or self._skip_paste_collapse:
|
||||
_paste_just_collapsed[0] = False
|
||||
self._skip_paste_collapse = False
|
||||
_prev_newline_count[0] = text.count('\n')
|
||||
return
|
||||
line_count = text.count('\n')
|
||||
@@ -9314,12 +9569,10 @@ class HermesCLI:
|
||||
is_paste = chars_added > 1 or newlines_added >= 4
|
||||
if line_count >= 5 and is_paste and not text.startswith('/'):
|
||||
_paste_counter[0] += 1
|
||||
# Save to temp file
|
||||
paste_dir = _hermes_home / "pastes"
|
||||
paste_dir.mkdir(parents=True, exist_ok=True)
|
||||
paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
|
||||
paste_file.write_text(text, encoding="utf-8")
|
||||
# Replace buffer with compact reference
|
||||
_paste_just_collapsed[0] = True
|
||||
buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]"
|
||||
buf.cursor_position = len(buf.text)
|
||||
@@ -10041,45 +10294,9 @@ class HermesCLI:
|
||||
_paste_ref_re = _re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
|
||||
paste_refs = list(_paste_ref_re.finditer(user_input)) if isinstance(user_input, str) else []
|
||||
if paste_refs:
|
||||
def _expand_ref(m):
|
||||
p = Path(m.group(1))
|
||||
return p.read_text(encoding="utf-8") if p.exists() else m.group(0)
|
||||
expanded = _paste_ref_re.sub(_expand_ref, user_input)
|
||||
total_lines = expanded.count('\n') + 1
|
||||
n_pastes = len(paste_refs)
|
||||
_user_bar = f"[{_accent_hex()}]{'─' * 40}[/]"
|
||||
print()
|
||||
ChatConsole().print(_user_bar)
|
||||
# Show any surrounding user text alongside the paste summary
|
||||
split_parts = _paste_ref_re.split(user_input)
|
||||
visible_user_text = " ".join(
|
||||
split_parts[i].strip() for i in range(0, len(split_parts), 2) if split_parts[i].strip()
|
||||
)
|
||||
if visible_user_text:
|
||||
ChatConsole().print(
|
||||
f"[bold {_accent_hex()}]\u25cf[/] [bold]{_escape(visible_user_text)}[/] "
|
||||
f"[dim]({n_pastes} pasted block{'s' if n_pastes > 1 else ''}, {total_lines} lines total)[/]"
|
||||
)
|
||||
else:
|
||||
ChatConsole().print(
|
||||
f"[bold {_accent_hex()}]\u25cf[/] [bold]{_escape(f'[Pasted text: {total_lines} lines]')}[/]"
|
||||
)
|
||||
user_input = expanded
|
||||
else:
|
||||
_user_bar = f"[{_accent_hex()}]{'─' * 40}[/]"
|
||||
if '\n' in user_input:
|
||||
first_line = user_input.split('\n')[0]
|
||||
line_count = user_input.count('\n') + 1
|
||||
print()
|
||||
ChatConsole().print(_user_bar)
|
||||
ChatConsole().print(
|
||||
f"[bold {_accent_hex()}]●[/] [bold]{_escape(first_line)}[/] "
|
||||
f"[dim](+{line_count - 1} lines)[/]"
|
||||
)
|
||||
else:
|
||||
print()
|
||||
ChatConsole().print(_user_bar)
|
||||
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]")
|
||||
user_input = self._expand_paste_references(user_input)
|
||||
print()
|
||||
self._print_user_message_preview(user_input)
|
||||
|
||||
# Show image attachment count
|
||||
if submit_images:
|
||||
@@ -10538,7 +10755,6 @@ def main(
|
||||
if cli._init_agent(
|
||||
model_override=turn_route["model"],
|
||||
runtime_override=turn_route["runtime"],
|
||||
route_label=turn_route["label"],
|
||||
request_overrides=turn_route.get("request_overrides"),
|
||||
):
|
||||
cli.agent.quiet_mode = True
|
||||
@@ -10552,6 +10768,15 @@ def main(
|
||||
user_message=effective_query,
|
||||
conversation_history=cli.conversation_history,
|
||||
)
|
||||
# Sync session_id if mid-run compression created a
|
||||
# continuation session. The exit line below reports
|
||||
# session_id to stderr for automation wrappers; without
|
||||
# this sync it would point at the ended parent.
|
||||
if (
|
||||
getattr(cli.agent, "session_id", None)
|
||||
and cli.agent.session_id != cli.session_id
|
||||
):
|
||||
cli.session_id = cli.agent.session_id
|
||||
response = result.get("final_response", "") if isinstance(result, dict) else str(result)
|
||||
if response:
|
||||
print(response)
|
||||
|
||||
+8
-24
@@ -826,7 +826,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
|
||||
# Provider routing
|
||||
pr = _cfg.get("provider_routing", {})
|
||||
smart_routing = _cfg.get("smart_model_routing", {}) or {}
|
||||
|
||||
from hermes_cli.runtime_provider import (
|
||||
resolve_runtime_provider,
|
||||
@@ -843,24 +842,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
message = format_runtime_provider_error(exc)
|
||||
raise RuntimeError(message) from exc
|
||||
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
turn_route = resolve_turn_route(
|
||||
prompt,
|
||||
smart_routing,
|
||||
{
|
||||
"model": model,
|
||||
"api_key": runtime.get("api_key"),
|
||||
"base_url": runtime.get("base_url"),
|
||||
"provider": runtime.get("provider"),
|
||||
"api_mode": runtime.get("api_mode"),
|
||||
"command": runtime.get("command"),
|
||||
"args": list(runtime.get("args") or []),
|
||||
},
|
||||
)
|
||||
|
||||
fallback_model = _cfg.get("fallback_providers") or _cfg.get("fallback_model") or None
|
||||
credential_pool = None
|
||||
runtime_provider = str(turn_route["runtime"].get("provider") or "").strip().lower()
|
||||
runtime_provider = str(runtime.get("provider") or "").strip().lower()
|
||||
if runtime_provider:
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
@@ -877,13 +861,13 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
logger.debug("Job '%s': failed to load credential pool for %s: %s", job_id, runtime_provider, e)
|
||||
|
||||
agent = AIAgent(
|
||||
model=turn_route["model"],
|
||||
api_key=turn_route["runtime"].get("api_key"),
|
||||
base_url=turn_route["runtime"].get("base_url"),
|
||||
provider=turn_route["runtime"].get("provider"),
|
||||
api_mode=turn_route["runtime"].get("api_mode"),
|
||||
acp_command=turn_route["runtime"].get("command"),
|
||||
acp_args=turn_route["runtime"].get("args"),
|
||||
model=model,
|
||||
api_key=runtime.get("api_key"),
|
||||
base_url=runtime.get("base_url"),
|
||||
provider=runtime.get("provider"),
|
||||
api_mode=runtime.get("api_mode"),
|
||||
acp_command=runtime.get("command"),
|
||||
acp_args=runtime.get("args"),
|
||||
max_iterations=max_iterations,
|
||||
reasoning_config=reasoning_config,
|
||||
prefill_messages=prefill_messages,
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
# Hermes Agent — ACP (Agent Client Protocol) Setup Guide
|
||||
|
||||
Hermes Agent supports the **Agent Client Protocol (ACP)**, allowing it to run as
|
||||
a coding agent inside your editor. ACP lets your IDE send tasks to Hermes, and
|
||||
Hermes responds with file edits, terminal commands, and explanations — all shown
|
||||
natively in the editor UI.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Hermes Agent installed and configured (`hermes setup` completed)
|
||||
- An API key / provider set up in `~/.hermes/.env` or via `hermes login`
|
||||
- Python 3.11+
|
||||
|
||||
Install the ACP extra:
|
||||
|
||||
```bash
|
||||
pip install -e ".[acp]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VS Code Setup
|
||||
|
||||
### 1. Install the ACP Client extension
|
||||
|
||||
Open VS Code and install **ACP Client** from the marketplace:
|
||||
|
||||
- Press `Ctrl+Shift+X` (or `Cmd+Shift+X` on macOS)
|
||||
- Search for **"ACP Client"**
|
||||
- Click **Install**
|
||||
|
||||
Or install from the command line:
|
||||
|
||||
```bash
|
||||
code --install-extension anysphere.acp-client
|
||||
```
|
||||
|
||||
### 2. Configure settings.json
|
||||
|
||||
Open your VS Code settings (`Ctrl+,` → click the `{}` icon for JSON) and add:
|
||||
|
||||
```json
|
||||
{
|
||||
"acpClient.agents": [
|
||||
{
|
||||
"name": "hermes-agent",
|
||||
"registryDir": "/path/to/hermes-agent/acp_registry"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Replace `/path/to/hermes-agent` with the actual path to your Hermes Agent
|
||||
installation (e.g. `~/.hermes/hermes-agent`).
|
||||
|
||||
Alternatively, if `hermes` is on your PATH, the ACP Client can discover it
|
||||
automatically via the registry directory.
|
||||
|
||||
### 3. Restart VS Code
|
||||
|
||||
After configuring, restart VS Code. You should see **Hermes Agent** appear in
|
||||
the ACP agent picker in the chat/agent panel.
|
||||
|
||||
---
|
||||
|
||||
## Zed Setup
|
||||
|
||||
Zed has built-in ACP support.
|
||||
|
||||
### 1. Configure Zed settings
|
||||
|
||||
Open Zed settings (`Cmd+,` on macOS or `Ctrl+,` on Linux) and add to your
|
||||
`settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"hermes-agent": {
|
||||
"type": "custom",
|
||||
"command": "hermes",
|
||||
"args": ["acp"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Restart Zed
|
||||
|
||||
Hermes Agent will appear in the agent panel. Select it and start a conversation.
|
||||
|
||||
---
|
||||
|
||||
## JetBrains Setup (IntelliJ, PyCharm, WebStorm, etc.)
|
||||
|
||||
### 1. Install the ACP plugin
|
||||
|
||||
- Open **Settings** → **Plugins** → **Marketplace**
|
||||
- Search for **"ACP"** or **"Agent Client Protocol"**
|
||||
- Install and restart the IDE
|
||||
|
||||
### 2. Configure the agent
|
||||
|
||||
- Open **Settings** → **Tools** → **ACP Agents**
|
||||
- Click **+** to add a new agent
|
||||
- Set the registry directory to your `acp_registry/` folder:
|
||||
`/path/to/hermes-agent/acp_registry`
|
||||
- Click **OK**
|
||||
|
||||
### 3. Use the agent
|
||||
|
||||
Open the ACP panel (usually in the right sidebar) and select **Hermes Agent**.
|
||||
|
||||
---
|
||||
|
||||
## What You Will See
|
||||
|
||||
Once connected, your editor provides a native interface to Hermes Agent:
|
||||
|
||||
### Chat Panel
|
||||
A conversational interface where you can describe tasks, ask questions, and
|
||||
give instructions. Hermes responds with explanations and actions.
|
||||
|
||||
### File Diffs
|
||||
When Hermes edits files, you see standard diffs in the editor. You can:
|
||||
- **Accept** individual changes
|
||||
- **Reject** changes you don't want
|
||||
- **Review** the full diff before applying
|
||||
|
||||
### Terminal Commands
|
||||
When Hermes needs to run shell commands (builds, tests, installs), the editor
|
||||
shows them in an integrated terminal. Depending on your settings:
|
||||
- Commands may run automatically
|
||||
- Or you may be prompted to **approve** each command
|
||||
|
||||
### Approval Flow
|
||||
For potentially destructive operations, the editor will prompt you for
|
||||
approval before Hermes proceeds. This includes:
|
||||
- File deletions
|
||||
- Shell commands
|
||||
- Git operations
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Hermes Agent under ACP uses the **same configuration** as the CLI:
|
||||
|
||||
- **API keys / providers**: `~/.hermes/.env`
|
||||
- **Agent config**: `~/.hermes/config.yaml`
|
||||
- **Skills**: `~/.hermes/skills/`
|
||||
- **Sessions**: `~/.hermes/state.db`
|
||||
|
||||
You can run `hermes setup` to configure providers, or edit `~/.hermes/.env`
|
||||
directly.
|
||||
|
||||
### Changing the model
|
||||
|
||||
Edit `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
model: openrouter/nous/hermes-3-llama-3.1-70b
|
||||
```
|
||||
|
||||
Or set the `HERMES_MODEL` environment variable.
|
||||
|
||||
### Toolsets
|
||||
|
||||
ACP sessions use the curated `hermes-acp` toolset by default. It is designed for editor workflows and intentionally excludes things like messaging delivery, cronjob management, and audio-first UX features.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent doesn't appear in the editor
|
||||
|
||||
1. **Check the registry path** — make sure the `acp_registry/` directory path
|
||||
in your editor settings is correct and contains `agent.json`.
|
||||
2. **Check `hermes` is on PATH** — run `which hermes` in a terminal. If not
|
||||
found, you may need to activate your virtualenv or add it to PATH.
|
||||
3. **Restart the editor** after changing settings.
|
||||
|
||||
### Agent starts but errors immediately
|
||||
|
||||
1. Run `hermes doctor` to check your configuration.
|
||||
2. Check that you have a valid API key: `hermes status`
|
||||
3. Try running `hermes acp` directly in a terminal to see error output.
|
||||
|
||||
### "Module not found" errors
|
||||
|
||||
Make sure you installed the ACP extra:
|
||||
|
||||
```bash
|
||||
pip install -e ".[acp]"
|
||||
```
|
||||
|
||||
### Slow responses
|
||||
|
||||
- ACP streams responses, so you should see incremental output. If the agent
|
||||
appears stuck, check your network connection and API provider status.
|
||||
- Some providers have rate limits. Try switching to a different model/provider.
|
||||
|
||||
### Permission denied for terminal commands
|
||||
|
||||
If the editor blocks terminal commands, check your ACP Client extension
|
||||
settings for auto-approval or manual-approval preferences.
|
||||
|
||||
### Logs
|
||||
|
||||
Hermes logs are written to stderr when running in ACP mode. Check:
|
||||
- VS Code: **Output** panel → select **ACP Client** or **Hermes Agent**
|
||||
- Zed: **View** → **Toggle Terminal** and check the process output
|
||||
- JetBrains: **Event Log** or the ACP tool window
|
||||
|
||||
You can also enable verbose logging:
|
||||
|
||||
```bash
|
||||
HERMES_LOG_LEVEL=DEBUG hermes acp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [ACP Specification](https://github.com/anysphere/acp)
|
||||
- [Hermes Agent Documentation](https://github.com/NousResearch/hermes-agent)
|
||||
- Run `hermes --help` for all CLI options
|
||||
@@ -1,698 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>honcho-integration-spec</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0e14;
|
||||
--bg-surface: #11151c;
|
||||
--bg-elevated: #181d27;
|
||||
--bg-code: #0d1018;
|
||||
--fg: #c9d1d9;
|
||||
--fg-bright: #e6edf3;
|
||||
--fg-muted: #6e7681;
|
||||
--fg-subtle: #484f58;
|
||||
--accent: #7eb8f6;
|
||||
--accent-dim: #3d6ea5;
|
||||
--accent-glow: rgba(126, 184, 246, 0.08);
|
||||
--green: #7ee6a8;
|
||||
--green-dim: #2ea04f;
|
||||
--orange: #e6a855;
|
||||
--red: #f47067;
|
||||
--purple: #bc8cff;
|
||||
--cyan: #56d4dd;
|
||||
--border: #21262d;
|
||||
--border-subtle: #161b22;
|
||||
--radius: 6px;
|
||||
--font-sans: 'New York', ui-serif, 'Iowan Old Style', 'Apple Garamond', Baskerville, 'Times New Roman', 'Noto Emoji', serif;
|
||||
--font-mono: 'Departure Mono', 'Noto Emoji', monospace;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; scroll-padding-top: 2rem; }
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
line-height: 1.7;
|
||||
font-size: 15px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.container { max-width: 860px; margin: 0 auto; padding: 3rem 2rem 6rem; }
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 4rem 0 3rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
.hero h1 { font-family: var(--font-mono); font-size: 2.2rem; font-weight: 700; color: var(--fg-bright); letter-spacing: -0.03em; margin-bottom: 0.5rem; }
|
||||
.hero h1 span { color: var(--accent); }
|
||||
.hero .subtitle { font-family: var(--font-sans); color: var(--fg-muted); font-size: 0.92rem; max-width: 560px; margin: 0 auto; line-height: 1.6; }
|
||||
.hero .meta { margin-top: 1.5rem; display: flex; justify-content: center; gap: 1.5rem; flex-wrap: wrap; }
|
||||
.hero .meta span { font-size: 0.8rem; color: var(--fg-subtle); font-family: var(--font-mono); }
|
||||
|
||||
.toc { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem 2rem; margin-bottom: 3rem; }
|
||||
.toc h2 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--fg-muted); margin-bottom: 1rem; }
|
||||
.toc ol { list-style: none; counter-reset: toc; columns: 2; column-gap: 2rem; }
|
||||
.toc li { counter-increment: toc; break-inside: avoid; margin-bottom: 0.35rem; }
|
||||
.toc li::before { content: counter(toc, decimal-leading-zero) " "; color: var(--fg-subtle); font-family: var(--font-mono); font-size: 0.75rem; margin-right: 0.25rem; }
|
||||
.toc a { font-family: var(--font-mono); color: var(--fg); text-decoration: none; font-size: 0.82rem; transition: color 0.15s; }
|
||||
.toc a:hover { color: var(--accent); }
|
||||
|
||||
section { margin-bottom: 4rem; }
|
||||
section + section { padding-top: 1rem; }
|
||||
|
||||
h2 { font-family: var(--font-mono); font-size: 1.3rem; font-weight: 700; color: var(--fg-bright); letter-spacing: -0.01em; margin-bottom: 1.25rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); }
|
||||
h3 { font-family: var(--font-mono); font-size: 1rem; font-weight: 600; color: var(--fg-bright); margin-top: 2rem; margin-bottom: 0.75rem; }
|
||||
h4 { font-family: var(--font-mono); font-size: 0.9rem; font-weight: 600; color: var(--accent); margin-top: 1.5rem; margin-bottom: 0.5rem; }
|
||||
|
||||
p { margin-bottom: 1rem; font-size: 0.95rem; line-height: 1.75; }
|
||||
strong { color: var(--fg-bright); font-weight: 600; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
ul, ol { margin-bottom: 1rem; padding-left: 1.5rem; font-size: 0.93rem; line-height: 1.7; }
|
||||
li { margin-bottom: 0.35rem; }
|
||||
li::marker { color: var(--fg-subtle); }
|
||||
|
||||
.table-wrap { overflow-x: auto; margin-bottom: 1.5rem; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||||
th, td { text-align: left; padding: 0.6rem 1rem; border-bottom: 1px solid var(--border-subtle); }
|
||||
th { font-family: var(--font-mono); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--fg-muted); background: var(--bg-surface); border-bottom-color: var(--border); white-space: nowrap; }
|
||||
td { font-family: var(--font-sans); font-size: 0.88rem; color: var(--fg); }
|
||||
tr:hover td { background: var(--accent-glow); }
|
||||
td code { background: var(--bg-elevated); padding: 0.15em 0.4em; border-radius: 3px; font-family: var(--font-mono); font-size: 0.82em; color: var(--cyan); }
|
||||
|
||||
pre { background: var(--bg-code); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem 1.5rem; overflow-x: auto; margin-bottom: 1.5rem; font-family: var(--font-mono); font-size: 0.82rem; line-height: 1.65; color: var(--fg); }
|
||||
pre code { background: none; padding: 0; color: inherit; font-size: inherit; }
|
||||
code { font-family: var(--font-mono); font-size: 0.85em; }
|
||||
p code, li code { background: var(--bg-elevated); padding: 0.15em 0.4em; border-radius: 3px; color: var(--cyan); font-size: 0.85em; }
|
||||
|
||||
.kw { color: var(--purple); }
|
||||
.str { color: var(--green); }
|
||||
.cm { color: var(--fg-subtle); font-style: italic; }
|
||||
.num { color: var(--orange); }
|
||||
.key { color: var(--accent); }
|
||||
|
||||
.mermaid { margin: 1.5rem 0 2rem; text-align: center; }
|
||||
.mermaid svg { max-width: 100%; height: auto; }
|
||||
|
||||
.callout { font-family: var(--font-sans); background: var(--bg-surface); border-left: 3px solid var(--accent-dim); border-radius: 0 var(--radius) var(--radius) 0; padding: 1rem 1.25rem; margin-bottom: 1.5rem; font-size: 0.88rem; color: var(--fg-muted); line-height: 1.6; }
|
||||
.callout strong { font-family: var(--font-mono); color: var(--fg-bright); }
|
||||
.callout.success { border-left-color: var(--green-dim); }
|
||||
.callout.warn { border-left-color: var(--orange); }
|
||||
|
||||
.badge { display: inline-block; font-family: var(--font-mono); font-size: 0.65rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.2em 0.6em; border-radius: 3px; vertical-align: middle; margin-left: 0.4rem; }
|
||||
.badge-done { background: var(--green-dim); color: #fff; }
|
||||
.badge-wip { background: var(--orange); color: #0b0e14; }
|
||||
.badge-todo { background: var(--fg-subtle); color: var(--fg); }
|
||||
|
||||
.checklist { list-style: none; padding-left: 0; }
|
||||
.checklist li { padding-left: 1.5rem; position: relative; margin-bottom: 0.5rem; }
|
||||
.checklist li::before { position: absolute; left: 0; font-family: var(--font-mono); font-size: 0.85rem; }
|
||||
.checklist li.done { color: var(--fg-muted); }
|
||||
.checklist li.done::before { content: "\2713"; color: var(--green); }
|
||||
.checklist li.todo::before { content: "\25CB"; color: var(--fg-subtle); }
|
||||
.checklist li.wip::before { content: "\25D4"; color: var(--orange); }
|
||||
|
||||
.compare { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 2rem; }
|
||||
.compare-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem; }
|
||||
.compare-card h4 { margin-top: 0; font-size: 0.82rem; }
|
||||
.compare-card.after { border-color: var(--accent-dim); }
|
||||
.compare-card ul { font-family: var(--font-mono); padding-left: 1.25rem; font-size: 0.8rem; }
|
||||
|
||||
hr { border: none; border-top: 1px solid var(--border); margin: 3rem 0; }
|
||||
|
||||
.progress-bar { position: fixed; top: 0; left: 0; height: 2px; background: var(--accent); z-index: 999; transition: width 0.1s linear; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.container { padding: 2rem 1rem 4rem; }
|
||||
.hero h1 { font-size: 1.6rem; }
|
||||
.toc ol { columns: 1; }
|
||||
.compare { grid-template-columns: 1fr; }
|
||||
table { font-size: 0.8rem; }
|
||||
th, td { padding: 0.4rem 0.6rem; }
|
||||
}
|
||||
</style>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Emoji&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Departure Mono';
|
||||
src: url('https://cdn.jsdelivr.net/gh/rektdeckard/departure-mono@latest/fonts/DepartureMono-Regular.woff2') format('woff2');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="progress-bar" id="progress"></div>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<header class="hero">
|
||||
<h1>honcho<span>-integration-spec</span></h1>
|
||||
<p class="subtitle">Comparison of Hermes Agent vs. openclaw-honcho — and a porting spec for bringing Hermes patterns into other Honcho integrations.</p>
|
||||
<div class="meta">
|
||||
<span>hermes-agent / openclaw-honcho</span>
|
||||
<span>Python + TypeScript</span>
|
||||
<span>2026-03-09</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="toc">
|
||||
<h2>Contents</h2>
|
||||
<ol>
|
||||
<li><a href="#overview">Overview</a></li>
|
||||
<li><a href="#architecture">Architecture comparison</a></li>
|
||||
<li><a href="#diff-table">Diff table</a></li>
|
||||
<li><a href="#patterns">Hermes patterns to port</a></li>
|
||||
<li><a href="#spec-async">Spec: async prefetch</a></li>
|
||||
<li><a href="#spec-reasoning">Spec: dynamic reasoning level</a></li>
|
||||
<li><a href="#spec-modes">Spec: per-peer memory modes</a></li>
|
||||
<li><a href="#spec-identity">Spec: AI peer identity formation</a></li>
|
||||
<li><a href="#spec-sessions">Spec: session naming strategies</a></li>
|
||||
<li><a href="#spec-cli">Spec: CLI surface injection</a></li>
|
||||
<li><a href="#openclaw-checklist">openclaw-honcho checklist</a></li>
|
||||
<li><a href="#nanobot-checklist">nanobot-honcho checklist</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- OVERVIEW -->
|
||||
<section id="overview">
|
||||
<h2>Overview</h2>
|
||||
|
||||
<p>Two independent Honcho integrations have been built for two different agent runtimes: <strong>Hermes Agent</strong> (Python, baked into the runner) and <strong>openclaw-honcho</strong> (TypeScript plugin via hook/tool API). Both use the same Honcho peer paradigm — dual peer model, <code>session.context()</code>, <code>peer.chat()</code> — but they made different tradeoffs at every layer.</p>
|
||||
|
||||
<p>This document maps those tradeoffs and defines a porting spec: a set of Hermes-originated patterns, each stated as an integration-agnostic interface, that any Honcho integration can adopt regardless of runtime or language.</p>
|
||||
|
||||
<div class="callout">
|
||||
<strong>Scope</strong> Both integrations work correctly today. This spec is about the delta — patterns in Hermes that are worth propagating and patterns in openclaw-honcho that Hermes should eventually adopt. The spec is additive, not prescriptive.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ARCHITECTURE -->
|
||||
<section id="architecture">
|
||||
<h2>Architecture comparison</h2>
|
||||
|
||||
<h3>Hermes: baked-in runner</h3>
|
||||
<p>Honcho is initialised directly inside <code>AIAgent.__init__</code>. There is no plugin boundary. Session management, context injection, async prefetch, and CLI surface are all first-class concerns of the runner. Context is injected once per session (baked into <code>_cached_system_prompt</code>) and never re-fetched mid-session — this maximises prefix cache hits at the LLM provider.</p>
|
||||
|
||||
<div class="mermaid">
|
||||
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1f3150', 'primaryTextColor': '#c9d1d9', 'primaryBorderColor': '#3d6ea5', 'lineColor': '#3d6ea5', 'secondaryColor': '#162030', 'tertiaryColor': '#11151c' }}}%%
|
||||
flowchart TD
|
||||
U["user message"] --> P["_honcho_prefetch()<br/>(reads cache — no HTTP)"]
|
||||
P --> SP["_build_system_prompt()<br/>(first turn only, cached)"]
|
||||
SP --> LLM["LLM call"]
|
||||
LLM --> R["response"]
|
||||
R --> FP["_honcho_fire_prefetch()<br/>(daemon threads, turn end)"]
|
||||
FP --> C1["prefetch_context() thread"]
|
||||
FP --> C2["prefetch_dialectic() thread"]
|
||||
C1 --> CACHE["_context_cache / _dialectic_cache"]
|
||||
C2 --> CACHE
|
||||
|
||||
style U fill:#162030,stroke:#3d6ea5,color:#c9d1d9
|
||||
style P fill:#1f3150,stroke:#3d6ea5,color:#c9d1d9
|
||||
style SP fill:#1f3150,stroke:#3d6ea5,color:#c9d1d9
|
||||
style LLM fill:#162030,stroke:#3d6ea5,color:#c9d1d9
|
||||
style R fill:#162030,stroke:#3d6ea5,color:#c9d1d9
|
||||
style FP fill:#2a1a40,stroke:#bc8cff,color:#c9d1d9
|
||||
style C1 fill:#2a1a40,stroke:#bc8cff,color:#c9d1d9
|
||||
style C2 fill:#2a1a40,stroke:#bc8cff,color:#c9d1d9
|
||||
style CACHE fill:#11151c,stroke:#484f58,color:#6e7681
|
||||
</div>
|
||||
|
||||
<h3>openclaw-honcho: hook-based plugin</h3>
|
||||
<p>The plugin registers hooks against OpenClaw's event bus. Context is fetched synchronously inside <code>before_prompt_build</code> on every turn. Message capture happens in <code>agent_end</code>. The multi-agent hierarchy is tracked via <code>subagent_spawned</code>. This model is correct but every turn pays a blocking Honcho round-trip before the LLM call can begin.</p>
|
||||
|
||||
<div class="mermaid">
|
||||
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1f3150', 'primaryTextColor': '#c9d1d9', 'primaryBorderColor': '#3d6ea5', 'lineColor': '#3d6ea5', 'secondaryColor': '#162030', 'tertiaryColor': '#11151c' }}}%%
|
||||
flowchart TD
|
||||
U2["user message"] --> BPB["before_prompt_build<br/>(BLOCKING HTTP — every turn)"]
|
||||
BPB --> CTX["session.context()"]
|
||||
CTX --> SP2["system prompt assembled"]
|
||||
SP2 --> LLM2["LLM call"]
|
||||
LLM2 --> R2["response"]
|
||||
R2 --> AE["agent_end hook"]
|
||||
AE --> SAVE["session.addMessages()<br/>session.setMetadata()"]
|
||||
|
||||
style U2 fill:#162030,stroke:#3d6ea5,color:#c9d1d9
|
||||
style BPB fill:#3a1515,stroke:#f47067,color:#c9d1d9
|
||||
style CTX fill:#3a1515,stroke:#f47067,color:#c9d1d9
|
||||
style SP2 fill:#1f3150,stroke:#3d6ea5,color:#c9d1d9
|
||||
style LLM2 fill:#162030,stroke:#3d6ea5,color:#c9d1d9
|
||||
style R2 fill:#162030,stroke:#3d6ea5,color:#c9d1d9
|
||||
style AE fill:#162030,stroke:#3d6ea5,color:#c9d1d9
|
||||
style SAVE fill:#11151c,stroke:#484f58,color:#6e7681
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DIFF TABLE -->
|
||||
<section id="diff-table">
|
||||
<h2>Diff table</h2>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dimension</th>
|
||||
<th>Hermes Agent</th>
|
||||
<th>openclaw-honcho</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Context injection timing</strong></td>
|
||||
<td>Once per session (cached). Zero HTTP on response path after turn 1.</td>
|
||||
<td>Every turn, blocking. Fresh context per turn but adds latency.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Prefetch strategy</strong></td>
|
||||
<td>Daemon threads fire at turn end; consumed next turn from cache.</td>
|
||||
<td>None. Blocking call at prompt-build time.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Dialectic (peer.chat)</strong></td>
|
||||
<td>Prefetched async; result injected into system prompt next turn.</td>
|
||||
<td>On-demand via <code>honcho_recall</code> / <code>honcho_analyze</code> tools.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Reasoning level</strong></td>
|
||||
<td>Dynamic: scales with message length. Floor = config default. Cap = "high".</td>
|
||||
<td>Fixed per tool: recall=minimal, analyze=medium.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Memory modes</strong></td>
|
||||
<td><code>user_memory_mode</code> / <code>agent_memory_mode</code>: hybrid / honcho / local.</td>
|
||||
<td>None. Always writes to Honcho.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Write frequency</strong></td>
|
||||
<td>async (background queue), turn, session, N turns.</td>
|
||||
<td>After every agent_end (no control).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>AI peer identity</strong></td>
|
||||
<td><code>observe_me=True</code>, <code>seed_ai_identity()</code>, <code>get_ai_representation()</code>, SOUL.md → AI peer.</td>
|
||||
<td>Agent files uploaded to agent peer at setup. No ongoing self-observation seeding.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Context scope</strong></td>
|
||||
<td>User peer + AI peer representation, both injected.</td>
|
||||
<td>User peer (owner) representation + conversation summary. <code>peerPerspective</code> on context call.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Session naming</strong></td>
|
||||
<td>per-directory / global / manual map / title-based.</td>
|
||||
<td>Derived from platform session key.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Multi-agent</strong></td>
|
||||
<td>Single-agent only.</td>
|
||||
<td>Parent observer hierarchy via <code>subagent_spawned</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Tool surface</strong></td>
|
||||
<td>Single <code>query_user_context</code> tool (on-demand dialectic).</td>
|
||||
<td>6 tools: session, profile, search, context (fast) + recall, analyze (LLM).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Platform metadata</strong></td>
|
||||
<td>Not stripped.</td>
|
||||
<td>Explicitly stripped before Honcho storage.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Message dedup</strong></td>
|
||||
<td>None (sends on every save cycle).</td>
|
||||
<td><code>lastSavedIndex</code> in session metadata prevents re-sending.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>CLI surface in prompt</strong></td>
|
||||
<td>Management commands injected into system prompt. Agent knows its own CLI.</td>
|
||||
<td>Not injected.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>AI peer name in identity</strong></td>
|
||||
<td>Replaces "Hermes Agent" in DEFAULT_AGENT_IDENTITY when configured.</td>
|
||||
<td>Not implemented.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>QMD / local file search</strong></td>
|
||||
<td>Not implemented.</td>
|
||||
<td>Passthrough tools when QMD backend configured.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Workspace metadata</strong></td>
|
||||
<td>Not implemented.</td>
|
||||
<td><code>agentPeerMap</code> in workspace metadata tracks agent→peer ID.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PATTERNS -->
|
||||
<section id="patterns">
|
||||
<h2>Hermes patterns to port</h2>
|
||||
|
||||
<p>Six patterns from Hermes are worth adopting in any Honcho integration. They are described below as integration-agnostic interfaces — the implementation will differ per runtime, but the contract is the same.</p>
|
||||
|
||||
<div class="compare">
|
||||
<div class="compare-card">
|
||||
<h4>Patterns Hermes contributes</h4>
|
||||
<ul>
|
||||
<li>Async prefetch (zero-latency)</li>
|
||||
<li>Dynamic reasoning level</li>
|
||||
<li>Per-peer memory modes</li>
|
||||
<li>AI peer identity formation</li>
|
||||
<li>Session naming strategies</li>
|
||||
<li>CLI surface injection</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="compare-card after">
|
||||
<h4>Patterns openclaw contributes back</h4>
|
||||
<ul>
|
||||
<li>lastSavedIndex dedup</li>
|
||||
<li>Platform metadata stripping</li>
|
||||
<li>Multi-agent observer hierarchy</li>
|
||||
<li>peerPerspective on context()</li>
|
||||
<li>Tiered tool surface (fast/LLM)</li>
|
||||
<li>Workspace agentPeerMap</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SPEC: ASYNC PREFETCH -->
|
||||
<section id="spec-async">
|
||||
<h2>Spec: async prefetch</h2>
|
||||
|
||||
<h3>Problem</h3>
|
||||
<p>Calling <code>session.context()</code> and <code>peer.chat()</code> synchronously before each LLM call adds 200–800ms of Honcho round-trip latency to every turn. Users experience this as the agent "thinking slowly."</p>
|
||||
|
||||
<h3>Pattern</h3>
|
||||
<p>Fire both calls as non-blocking background work at the <strong>end</strong> of each turn. Store results in a per-session cache keyed by session ID. At the <strong>start</strong> of the next turn, pop from cache — the HTTP is already done. First turn is cold (empty cache); all subsequent turns are zero-latency on the response path.</p>
|
||||
|
||||
<h3>Interface contract</h3>
|
||||
<pre><code><span class="cm">// TypeScript (openclaw / nanobot plugin shape)</span>
|
||||
|
||||
<span class="kw">interface</span> <span class="key">AsyncPrefetch</span> {
|
||||
<span class="cm">// Fire context + dialectic fetches at turn end. Non-blocking.</span>
|
||||
firePrefetch(sessionId: <span class="str">string</span>, userMessage: <span class="str">string</span>): <span class="kw">void</span>;
|
||||
|
||||
<span class="cm">// Pop cached results at turn start. Returns empty if cache is cold.</span>
|
||||
popContextResult(sessionId: <span class="str">string</span>): ContextResult | <span class="kw">null</span>;
|
||||
popDialecticResult(sessionId: <span class="str">string</span>): <span class="str">string</span> | <span class="kw">null</span>;
|
||||
}
|
||||
|
||||
<span class="kw">type</span> <span class="key">ContextResult</span> = {
|
||||
representation: <span class="str">string</span>;
|
||||
card: <span class="str">string</span>[];
|
||||
aiRepresentation?: <span class="str">string</span>; <span class="cm">// AI peer context if enabled</span>
|
||||
summary?: <span class="str">string</span>; <span class="cm">// conversation summary if fetched</span>
|
||||
};</code></pre>
|
||||
|
||||
<h3>Implementation notes</h3>
|
||||
<ul>
|
||||
<li>Python: <code>threading.Thread(daemon=True)</code>. Write to <code>dict[session_id, result]</code> — GIL makes this safe for simple writes.</li>
|
||||
<li>TypeScript: <code>Promise</code> stored in <code>Map<string, Promise<ContextResult>></code>. Await at pop time. If not resolved yet, skip (return null) — do not block.</li>
|
||||
<li>The pop is destructive: clears the cache entry after reading so stale data never accumulates.</li>
|
||||
<li>Prefetch should also fire on first turn (even though it won't be consumed until turn 2) — this ensures turn 2 is never cold.</li>
|
||||
</ul>
|
||||
|
||||
<h3>openclaw-honcho adoption</h3>
|
||||
<p>Move <code>session.context()</code> from <code>before_prompt_build</code> to a post-<code>agent_end</code> background task. Store result in <code>state.contextCache</code>. In <code>before_prompt_build</code>, read from cache instead of calling Honcho. If cache is empty (turn 1), inject nothing — the prompt is still valid without Honcho context on the first turn.</p>
|
||||
</section>
|
||||
|
||||
<!-- SPEC: DYNAMIC REASONING LEVEL -->
|
||||
<section id="spec-reasoning">
|
||||
<h2>Spec: dynamic reasoning level</h2>
|
||||
|
||||
<h3>Problem</h3>
|
||||
<p>Honcho's dialectic endpoint supports reasoning levels from <code>minimal</code> to <code>max</code>. A fixed level per tool wastes budget on simple queries and under-serves complex ones.</p>
|
||||
|
||||
<h3>Pattern</h3>
|
||||
<p>Select the reasoning level dynamically based on the user's message. Use the configured default as a floor. Bump by message length. Cap auto-selection at <code>high</code> — never select <code>max</code> automatically.</p>
|
||||
|
||||
<h3>Interface contract</h3>
|
||||
<pre><code><span class="cm">// Shared helper — identical logic in any language</span>
|
||||
|
||||
<span class="kw">const</span> LEVELS = [<span class="str">"minimal"</span>, <span class="str">"low"</span>, <span class="str">"medium"</span>, <span class="str">"high"</span>, <span class="str">"max"</span>];
|
||||
|
||||
<span class="kw">function</span> <span class="key">dynamicReasoningLevel</span>(
|
||||
query: <span class="str">string</span>,
|
||||
configDefault: <span class="str">string</span> = <span class="str">"low"</span>
|
||||
): <span class="str">string</span> {
|
||||
<span class="kw">const</span> baseIdx = Math.max(<span class="num">0</span>, LEVELS.indexOf(configDefault));
|
||||
<span class="kw">const</span> n = query.length;
|
||||
<span class="kw">const</span> bump = n < <span class="num">120</span> ? <span class="num">0</span> : n < <span class="num">400</span> ? <span class="num">1</span> : <span class="num">2</span>;
|
||||
<span class="kw">return</span> LEVELS[Math.min(baseIdx + bump, <span class="num">3</span>)]; <span class="cm">// cap at "high" (idx 3)</span>
|
||||
}</code></pre>
|
||||
|
||||
<h3>Config key</h3>
|
||||
<p>Add a <code>dialecticReasoningLevel</code> config field (string, default <code>"low"</code>). This sets the floor. Users can raise or lower it. The dynamic bump always applies on top.</p>
|
||||
|
||||
<h3>openclaw-honcho adoption</h3>
|
||||
<p>Apply in <code>honcho_recall</code> and <code>honcho_analyze</code>: replace the fixed <code>reasoningLevel</code> with the dynamic selector. <code>honcho_recall</code> should use floor <code>"minimal"</code> and <code>honcho_analyze</code> floor <code>"medium"</code> — both still bump with message length.</p>
|
||||
</section>
|
||||
|
||||
<!-- SPEC: PER-PEER MEMORY MODES -->
|
||||
<section id="spec-modes">
|
||||
<h2>Spec: per-peer memory modes</h2>
|
||||
|
||||
<h3>Problem</h3>
|
||||
<p>Users want independent control over whether user context and agent context are written locally, to Honcho, or both. A single <code>memoryMode</code> shorthand is not granular enough.</p>
|
||||
|
||||
<h3>Pattern</h3>
|
||||
<p>Three modes per peer: <code>hybrid</code> (write both local + Honcho), <code>honcho</code> (Honcho only, disable local files), <code>local</code> (local files only, skip Honcho sync for this peer). Two orthogonal axes: user peer and agent peer.</p>
|
||||
|
||||
<h3>Config schema</h3>
|
||||
<pre><code><span class="cm">// ~/.openclaw/openclaw.json (or ~/.nanobot/config.json)</span>
|
||||
{
|
||||
<span class="str">"plugins"</span>: {
|
||||
<span class="str">"openclaw-honcho"</span>: {
|
||||
<span class="str">"config"</span>: {
|
||||
<span class="str">"apiKey"</span>: <span class="str">"..."</span>,
|
||||
<span class="str">"memoryMode"</span>: <span class="str">"hybrid"</span>, <span class="cm">// shorthand: both peers</span>
|
||||
<span class="str">"userMemoryMode"</span>: <span class="str">"honcho"</span>, <span class="cm">// override for user peer</span>
|
||||
<span class="str">"agentMemoryMode"</span>: <span class="str">"hybrid"</span> <span class="cm">// override for agent peer</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<h3>Resolution order</h3>
|
||||
<ol>
|
||||
<li>Per-peer field (<code>userMemoryMode</code> / <code>agentMemoryMode</code>) — wins if present.</li>
|
||||
<li>Shorthand <code>memoryMode</code> — applies to both peers as default.</li>
|
||||
<li>Hardcoded default: <code>"hybrid"</code>.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Effect on Honcho sync</h3>
|
||||
<ul>
|
||||
<li><code>userMemoryMode=local</code>: skip adding user peer messages to Honcho.</li>
|
||||
<li><code>agentMemoryMode=local</code>: skip adding assistant peer messages to Honcho.</li>
|
||||
<li>Both local: skip <code>session.addMessages()</code> entirely.</li>
|
||||
<li><code>userMemoryMode=honcho</code>: disable local USER.md writes.</li>
|
||||
<li><code>agentMemoryMode=honcho</code>: disable local MEMORY.md / SOUL.md writes.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- SPEC: AI PEER IDENTITY -->
|
||||
<section id="spec-identity">
|
||||
<h2>Spec: AI peer identity formation</h2>
|
||||
|
||||
<h3>Problem</h3>
|
||||
<p>Honcho builds the user's representation organically by observing what the user says. The same mechanism exists for the AI peer — but only if <code>observe_me=True</code> is set for the agent peer. Without it, the agent peer accumulates nothing and Honcho's AI-side model never forms.</p>
|
||||
|
||||
<p>Additionally, existing persona files (SOUL.md, IDENTITY.md) should seed the AI peer's Honcho representation at first activation, rather than waiting for it to emerge from scratch.</p>
|
||||
|
||||
<h3>Part A: observe_me=True for agent peer</h3>
|
||||
<pre><code><span class="cm">// TypeScript — in session.addPeers() call</span>
|
||||
<span class="kw">await</span> session.addPeers([
|
||||
[ownerPeer.id, { observeMe: <span class="kw">true</span>, observeOthers: <span class="kw">false</span> }],
|
||||
[agentPeer.id, { observeMe: <span class="kw">true</span>, observeOthers: <span class="kw">true</span> }], <span class="cm">// was false</span>
|
||||
]);</code></pre>
|
||||
|
||||
<p>This is a one-line change but foundational. Without it, Honcho's AI peer representation stays empty regardless of what the agent says.</p>
|
||||
|
||||
<h3>Part B: seedAiIdentity()</h3>
|
||||
<pre><code><span class="kw">async function</span> <span class="key">seedAiIdentity</span>(
|
||||
session: HonchoSession,
|
||||
agentPeer: Peer,
|
||||
content: <span class="str">string</span>,
|
||||
source: <span class="str">string</span>
|
||||
): Promise<<span class="kw">boolean</span>> {
|
||||
<span class="kw">const</span> wrapped = [
|
||||
<span class="str">`<ai_identity_seed>`</span>,
|
||||
<span class="str">`<source>${source}</source>`</span>,
|
||||
<span class="str">``</span>,
|
||||
content.trim(),
|
||||
<span class="str">`</ai_identity_seed>`</span>,
|
||||
].join(<span class="str">"\n"</span>);
|
||||
|
||||
<span class="kw">await</span> agentPeer.addMessage(<span class="str">"assistant"</span>, wrapped);
|
||||
<span class="kw">return true</span>;
|
||||
}</code></pre>
|
||||
|
||||
<h3>Part C: migrate agent files at setup</h3>
|
||||
<p>During <code>openclaw honcho setup</code>, upload agent-self files (SOUL.md, IDENTITY.md, AGENTS.md, BOOTSTRAP.md) to the agent peer using <code>seedAiIdentity()</code> instead of <code>session.uploadFile()</code>. This routes the content through Honcho's observation pipeline rather than the file store.</p>
|
||||
|
||||
<h3>Part D: AI peer name in identity</h3>
|
||||
<p>When the agent has a configured name (non-default), inject it into the agent's self-identity prefix. In OpenClaw this means adding to the injected system prompt section:</p>
|
||||
<pre><code><span class="cm">// In context hook return value</span>
|
||||
<span class="kw">return</span> {
|
||||
systemPrompt: [
|
||||
agentName ? <span class="str">`You are ${agentName}.`</span> : <span class="str">""</span>,
|
||||
<span class="str">"## User Memory Context"</span>,
|
||||
...sections,
|
||||
].filter(Boolean).join(<span class="str">"\n\n"</span>)
|
||||
};</code></pre>
|
||||
|
||||
<h3>CLI surface: honcho identity subcommand</h3>
|
||||
<pre><code>openclaw honcho identity <file> <span class="cm"># seed from file</span>
|
||||
openclaw honcho identity --show <span class="cm"># show current AI peer representation</span></code></pre>
|
||||
</section>
|
||||
|
||||
<!-- SPEC: SESSION NAMING -->
|
||||
<section id="spec-sessions">
|
||||
<h2>Spec: session naming strategies</h2>
|
||||
|
||||
<h3>Problem</h3>
|
||||
<p>When Honcho is used across multiple projects or directories, a single global session means every project shares the same context. Per-directory sessions provide isolation without requiring users to name sessions manually.</p>
|
||||
|
||||
<h3>Strategies</h3>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Strategy</th><th>Session key</th><th>When to use</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>per-directory</code></td><td>basename of CWD</td><td>Default. Each project gets its own session.</td></tr>
|
||||
<tr><td><code>global</code></td><td>fixed string <code>"global"</code></td><td>Single cross-project session.</td></tr>
|
||||
<tr><td>manual map</td><td>user-configured per path</td><td><code>sessions</code> config map overrides directory basename.</td></tr>
|
||||
<tr><td>title-based</td><td>sanitized session title</td><td>When agent supports named sessions; title set mid-conversation.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Config schema</h3>
|
||||
<pre><code>{
|
||||
<span class="str">"sessionStrategy"</span>: <span class="str">"per-directory"</span>, <span class="cm">// "per-directory" | "global"</span>
|
||||
<span class="str">"sessionPeerPrefix"</span>: <span class="kw">false</span>, <span class="cm">// prepend peer name to session key</span>
|
||||
<span class="str">"sessions"</span>: { <span class="cm">// manual overrides</span>
|
||||
<span class="str">"/home/user/projects/foo"</span>: <span class="str">"foo-project"</span>
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<h3>CLI surface</h3>
|
||||
<pre><code>openclaw honcho sessions <span class="cm"># list all mappings</span>
|
||||
openclaw honcho map <name> <span class="cm"># map cwd to session name</span>
|
||||
openclaw honcho map <span class="cm"># no-arg = list mappings</span></code></pre>
|
||||
|
||||
<p>Resolution order: manual map wins → session title → directory basename → platform key.</p>
|
||||
</section>
|
||||
|
||||
<!-- SPEC: CLI SURFACE INJECTION -->
|
||||
<section id="spec-cli">
|
||||
<h2>Spec: CLI surface injection</h2>
|
||||
|
||||
<h3>Problem</h3>
|
||||
<p>When a user asks "how do I change my memory settings?" or "what Honcho commands are available?" the agent either hallucinates or says it doesn't know. The agent should know its own management interface.</p>
|
||||
|
||||
<h3>Pattern</h3>
|
||||
<p>When Honcho is active, append a compact command reference to the system prompt. The agent can cite these commands directly instead of guessing.</p>
|
||||
|
||||
<pre><code><span class="cm">// In context hook, append to systemPrompt</span>
|
||||
<span class="kw">const</span> honchoSection = [
|
||||
<span class="str">"# Honcho memory integration"</span>,
|
||||
<span class="str">`Active. Session: ${sessionKey}. Mode: ${mode}.`</span>,
|
||||
<span class="str">"Management commands:"</span>,
|
||||
<span class="str">" openclaw honcho status — show config + connection"</span>,
|
||||
<span class="str">" openclaw honcho mode [hybrid|honcho|local] — show or set memory mode"</span>,
|
||||
<span class="str">" openclaw honcho sessions — list session mappings"</span>,
|
||||
<span class="str">" openclaw honcho map <name> — map directory to session"</span>,
|
||||
<span class="str">" openclaw honcho identity [file] [--show] — seed or show AI identity"</span>,
|
||||
<span class="str">" openclaw honcho setup — full interactive wizard"</span>,
|
||||
].join(<span class="str">"\n"</span>);</code></pre>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Keep it compact.</strong> This section is injected every turn. Keep it under 300 chars of context. List commands, not explanations — the agent can explain them on request.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- OPENCLAW CHECKLIST -->
|
||||
<section id="openclaw-checklist">
|
||||
<h2>openclaw-honcho checklist</h2>
|
||||
|
||||
<p>Ordered by impact. Each item maps to a spec section above.</p>
|
||||
|
||||
<ul class="checklist">
|
||||
<li class="todo"><strong>Async prefetch</strong> — move <code>session.context()</code> out of <code>before_prompt_build</code> into post-<code>agent_end</code> background Promise. Pop from cache at prompt build. (<a href="#spec-async">spec</a>)</li>
|
||||
<li class="todo"><strong>observe_me=True for agent peer</strong> — one-line change in <code>session.addPeers()</code> config for agent peer. (<a href="#spec-identity">spec</a>)</li>
|
||||
<li class="todo"><strong>Dynamic reasoning level</strong> — add <code>dynamicReasoningLevel()</code> helper; apply in <code>honcho_recall</code> and <code>honcho_analyze</code>. Add <code>dialecticReasoningLevel</code> to config schema. (<a href="#spec-reasoning">spec</a>)</li>
|
||||
<li class="todo"><strong>Per-peer memory modes</strong> — add <code>userMemoryMode</code> / <code>agentMemoryMode</code> to config; gate Honcho sync and local writes accordingly. (<a href="#spec-modes">spec</a>)</li>
|
||||
<li class="todo"><strong>seedAiIdentity()</strong> — add helper; apply during setup migration for SOUL.md / IDENTITY.md instead of <code>session.uploadFile()</code>. (<a href="#spec-identity">spec</a>)</li>
|
||||
<li class="todo"><strong>Session naming strategies</strong> — add <code>sessionStrategy</code>, <code>sessions</code> map, <code>sessionPeerPrefix</code> to config; implement resolution function. (<a href="#spec-sessions">spec</a>)</li>
|
||||
<li class="todo"><strong>CLI surface injection</strong> — append command reference to <code>before_prompt_build</code> return value when Honcho is active. (<a href="#spec-cli">spec</a>)</li>
|
||||
<li class="todo"><strong>honcho identity subcommand</strong> — add <code>openclaw honcho identity</code> CLI command. (<a href="#spec-identity">spec</a>)</li>
|
||||
<li class="todo"><strong>AI peer name injection</strong> — if <code>aiPeer</code> name configured, prepend to injected system prompt. (<a href="#spec-identity">spec</a>)</li>
|
||||
<li class="todo"><strong>honcho mode / honcho sessions / honcho map</strong> — CLI parity with Hermes. (<a href="#spec-sessions">spec</a>)</li>
|
||||
</ul>
|
||||
|
||||
<div class="callout success">
|
||||
<strong>Already done in openclaw-honcho (do not re-implement):</strong> lastSavedIndex dedup, platform metadata stripping, multi-agent parent observer hierarchy, peerPerspective on context(), tiered tool surface (fast/LLM), workspace agentPeerMap, QMD passthrough, self-hosted Honcho support.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- NANOBOT CHECKLIST -->
|
||||
<section id="nanobot-checklist">
|
||||
<h2>nanobot-honcho checklist</h2>
|
||||
|
||||
<p>nanobot-honcho is a greenfield integration. Start from openclaw-honcho's architecture (hook-based, dual peer) and apply all Hermes patterns from day one rather than retrofitting. Priority order:</p>
|
||||
|
||||
<h3>Phase 1 — core correctness</h3>
|
||||
<ul class="checklist">
|
||||
<li class="todo">Dual peer model (owner + agent peer), both with <code>observe_me=True</code></li>
|
||||
<li class="todo">Message capture at turn end with <code>lastSavedIndex</code> dedup</li>
|
||||
<li class="todo">Platform metadata stripping before Honcho storage</li>
|
||||
<li class="todo">Async prefetch from day one — do not implement blocking context injection</li>
|
||||
<li class="todo">Legacy file migration at first activation (USER.md → owner peer, SOUL.md → <code>seedAiIdentity()</code>)</li>
|
||||
</ul>
|
||||
|
||||
<h3>Phase 2 — configuration</h3>
|
||||
<ul class="checklist">
|
||||
<li class="todo">Config schema: <code>apiKey</code>, <code>workspaceId</code>, <code>baseUrl</code>, <code>memoryMode</code>, <code>userMemoryMode</code>, <code>agentMemoryMode</code>, <code>dialecticReasoningLevel</code>, <code>sessionStrategy</code>, <code>sessions</code></li>
|
||||
<li class="todo">Per-peer memory mode gating</li>
|
||||
<li class="todo">Dynamic reasoning level</li>
|
||||
<li class="todo">Session naming strategies</li>
|
||||
</ul>
|
||||
|
||||
<h3>Phase 3 — tools and CLI</h3>
|
||||
<ul class="checklist">
|
||||
<li class="todo">Tool surface: <code>honcho_profile</code>, <code>honcho_recall</code>, <code>honcho_analyze</code>, <code>honcho_search</code>, <code>honcho_context</code></li>
|
||||
<li class="todo">CLI: <code>setup</code>, <code>status</code>, <code>sessions</code>, <code>map</code>, <code>mode</code>, <code>identity</code></li>
|
||||
<li class="todo">CLI surface injection into system prompt</li>
|
||||
<li class="todo">AI peer name wired into agent identity</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
||||
mermaid.initialize({ startOnLoad: true, securityLevel: 'loose', fontFamily: 'Departure Mono, Noto Emoji, monospace' });
|
||||
</script>
|
||||
<script>
|
||||
window.addEventListener('scroll', () => {
|
||||
const bar = document.getElementById('progress');
|
||||
const max = document.documentElement.scrollHeight - window.innerHeight;
|
||||
bar.style.width = (max > 0 ? (window.scrollY / max) * 100 : 0) + '%';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,377 +0,0 @@
|
||||
# honcho-integration-spec
|
||||
|
||||
Comparison of Hermes Agent vs. openclaw-honcho — and a porting spec for bringing Hermes patterns into other Honcho integrations.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Two independent Honcho integrations have been built for two different agent runtimes: **Hermes Agent** (Python, baked into the runner) and **openclaw-honcho** (TypeScript plugin via hook/tool API). Both use the same Honcho peer paradigm — dual peer model, `session.context()`, `peer.chat()` — but they made different tradeoffs at every layer.
|
||||
|
||||
This document maps those tradeoffs and defines a porting spec: a set of Hermes-originated patterns, each stated as an integration-agnostic interface, that any Honcho integration can adopt regardless of runtime or language.
|
||||
|
||||
> **Scope** Both integrations work correctly today. This spec is about the delta — patterns in Hermes that are worth propagating and patterns in openclaw-honcho that Hermes should eventually adopt. The spec is additive, not prescriptive.
|
||||
|
||||
---
|
||||
|
||||
## Architecture comparison
|
||||
|
||||
### Hermes: baked-in runner
|
||||
|
||||
Honcho is initialised directly inside `AIAgent.__init__`. There is no plugin boundary. Session management, context injection, async prefetch, and CLI surface are all first-class concerns of the runner. Context is injected once per session (baked into `_cached_system_prompt`) and never re-fetched mid-session — this maximises prefix cache hits at the LLM provider.
|
||||
|
||||
Turn flow:
|
||||
|
||||
```
|
||||
user message
|
||||
→ _honcho_prefetch() (reads cache — no HTTP)
|
||||
→ _build_system_prompt() (first turn only, cached)
|
||||
→ LLM call
|
||||
→ response
|
||||
→ _honcho_fire_prefetch() (daemon threads, turn end)
|
||||
→ prefetch_context() thread ──┐
|
||||
→ prefetch_dialectic() thread ─┴→ _context_cache / _dialectic_cache
|
||||
```
|
||||
|
||||
### openclaw-honcho: hook-based plugin
|
||||
|
||||
The plugin registers hooks against OpenClaw's event bus. Context is fetched synchronously inside `before_prompt_build` on every turn. Message capture happens in `agent_end`. The multi-agent hierarchy is tracked via `subagent_spawned`. This model is correct but every turn pays a blocking Honcho round-trip before the LLM call can begin.
|
||||
|
||||
Turn flow:
|
||||
|
||||
```
|
||||
user message
|
||||
→ before_prompt_build (BLOCKING HTTP — every turn)
|
||||
→ session.context()
|
||||
→ system prompt assembled
|
||||
→ LLM call
|
||||
→ response
|
||||
→ agent_end hook
|
||||
→ session.addMessages()
|
||||
→ session.setMetadata()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diff table
|
||||
|
||||
| Dimension | Hermes Agent | openclaw-honcho |
|
||||
|---|---|---|
|
||||
| **Context injection timing** | Once per session (cached). Zero HTTP on response path after turn 1. | Every turn, blocking. Fresh context per turn but adds latency. |
|
||||
| **Prefetch strategy** | Daemon threads fire at turn end; consumed next turn from cache. | None. Blocking call at prompt-build time. |
|
||||
| **Dialectic (peer.chat)** | Prefetched async; result injected into system prompt next turn. | On-demand via `honcho_recall` / `honcho_analyze` tools. |
|
||||
| **Reasoning level** | Dynamic: scales with message length. Floor = config default. Cap = "high". | Fixed per tool: recall=minimal, analyze=medium. |
|
||||
| **Memory modes** | `user_memory_mode` / `agent_memory_mode`: hybrid / honcho / local. | None. Always writes to Honcho. |
|
||||
| **Write frequency** | async (background queue), turn, session, N turns. | After every agent_end (no control). |
|
||||
| **AI peer identity** | `observe_me=True`, `seed_ai_identity()`, `get_ai_representation()`, SOUL.md → AI peer. | Agent files uploaded to agent peer at setup. No ongoing self-observation. |
|
||||
| **Context scope** | User peer + AI peer representation, both injected. | User peer (owner) representation + conversation summary. `peerPerspective` on context call. |
|
||||
| **Session naming** | per-directory / global / manual map / title-based. | Derived from platform session key. |
|
||||
| **Multi-agent** | Single-agent only. | Parent observer hierarchy via `subagent_spawned`. |
|
||||
| **Tool surface** | Single `query_user_context` tool (on-demand dialectic). | 6 tools: session, profile, search, context (fast) + recall, analyze (LLM). |
|
||||
| **Platform metadata** | Not stripped. | Explicitly stripped before Honcho storage. |
|
||||
| **Message dedup** | None. | `lastSavedIndex` in session metadata prevents re-sending. |
|
||||
| **CLI surface in prompt** | Management commands injected into system prompt. Agent knows its own CLI. | Not injected. |
|
||||
| **AI peer name in identity** | Replaces "Hermes Agent" in DEFAULT_AGENT_IDENTITY when configured. | Not implemented. |
|
||||
| **QMD / local file search** | Not implemented. | Passthrough tools when QMD backend configured. |
|
||||
| **Workspace metadata** | Not implemented. | `agentPeerMap` in workspace metadata tracks agent→peer ID. |
|
||||
|
||||
---
|
||||
|
||||
## Patterns
|
||||
|
||||
Six patterns from Hermes are worth adopting in any Honcho integration. Each is described as an integration-agnostic interface.
|
||||
|
||||
**Hermes contributes:**
|
||||
- Async prefetch (zero-latency)
|
||||
- Dynamic reasoning level
|
||||
- Per-peer memory modes
|
||||
- AI peer identity formation
|
||||
- Session naming strategies
|
||||
- CLI surface injection
|
||||
|
||||
**openclaw-honcho contributes back (Hermes should adopt):**
|
||||
- `lastSavedIndex` dedup
|
||||
- Platform metadata stripping
|
||||
- Multi-agent observer hierarchy
|
||||
- `peerPerspective` on `context()`
|
||||
- Tiered tool surface (fast/LLM)
|
||||
- Workspace `agentPeerMap`
|
||||
|
||||
---
|
||||
|
||||
## Spec: async prefetch
|
||||
|
||||
### Problem
|
||||
|
||||
Calling `session.context()` and `peer.chat()` synchronously before each LLM call adds 200–800ms of Honcho round-trip latency to every turn.
|
||||
|
||||
### Pattern
|
||||
|
||||
Fire both calls as non-blocking background work at the **end** of each turn. Store results in a per-session cache keyed by session ID. At the **start** of the next turn, pop from cache — the HTTP is already done. First turn is cold (empty cache); all subsequent turns are zero-latency on the response path.
|
||||
|
||||
### Interface contract
|
||||
|
||||
```typescript
|
||||
interface AsyncPrefetch {
|
||||
// Fire context + dialectic fetches at turn end. Non-blocking.
|
||||
firePrefetch(sessionId: string, userMessage: string): void;
|
||||
|
||||
// Pop cached results at turn start. Returns empty if cache is cold.
|
||||
popContextResult(sessionId: string): ContextResult | null;
|
||||
popDialecticResult(sessionId: string): string | null;
|
||||
}
|
||||
|
||||
type ContextResult = {
|
||||
representation: string;
|
||||
card: string[];
|
||||
aiRepresentation?: string; // AI peer context if enabled
|
||||
summary?: string; // conversation summary if fetched
|
||||
};
|
||||
```
|
||||
|
||||
### Implementation notes
|
||||
|
||||
- **Python:** `threading.Thread(daemon=True)`. Write to `dict[session_id, result]` — GIL makes this safe for simple writes.
|
||||
- **TypeScript:** `Promise` stored in `Map<string, Promise<ContextResult>>`. Await at pop time. If not resolved yet, return null — do not block.
|
||||
- The pop is destructive: clears the cache entry after reading so stale data never accumulates.
|
||||
- Prefetch should also fire on first turn (even though it won't be consumed until turn 2).
|
||||
|
||||
### openclaw-honcho adoption
|
||||
|
||||
Move `session.context()` from `before_prompt_build` to a post-`agent_end` background task. Store result in `state.contextCache`. In `before_prompt_build`, read from cache instead of calling Honcho. If cache is empty (turn 1), inject nothing — the prompt is still valid without Honcho context on the first turn.
|
||||
|
||||
---
|
||||
|
||||
## Spec: dynamic reasoning level
|
||||
|
||||
### Problem
|
||||
|
||||
Honcho's dialectic endpoint supports reasoning levels from `minimal` to `max`. A fixed level per tool wastes budget on simple queries and under-serves complex ones.
|
||||
|
||||
### Pattern
|
||||
|
||||
Select the reasoning level dynamically based on the user's message. Use the configured default as a floor. Bump by message length. Cap auto-selection at `high` — never select `max` automatically.
|
||||
|
||||
### Logic
|
||||
|
||||
```
|
||||
< 120 chars → default (typically "low")
|
||||
120–400 chars → one level above default (cap at "high")
|
||||
> 400 chars → two levels above default (cap at "high")
|
||||
```
|
||||
|
||||
### Config key
|
||||
|
||||
Add `dialecticReasoningLevel` (string, default `"low"`). This sets the floor. The dynamic bump always applies on top.
|
||||
|
||||
### openclaw-honcho adoption
|
||||
|
||||
Apply in `honcho_recall` and `honcho_analyze`: replace fixed `reasoningLevel` with the dynamic selector. `honcho_recall` uses floor `"minimal"`, `honcho_analyze` uses floor `"medium"` — both still bump with message length.
|
||||
|
||||
---
|
||||
|
||||
## Spec: per-peer memory modes
|
||||
|
||||
### Problem
|
||||
|
||||
Users want independent control over whether user context and agent context are written locally, to Honcho, or both.
|
||||
|
||||
### Modes
|
||||
|
||||
| Mode | Effect |
|
||||
|---|---|
|
||||
| `hybrid` | Write to both local files and Honcho (default) |
|
||||
| `honcho` | Honcho only — disable corresponding local file writes |
|
||||
| `local` | Local files only — skip Honcho sync for this peer |
|
||||
|
||||
### Config schema
|
||||
|
||||
```json
|
||||
{
|
||||
"memoryMode": "hybrid",
|
||||
"userMemoryMode": "honcho",
|
||||
"agentMemoryMode": "hybrid"
|
||||
}
|
||||
```
|
||||
|
||||
Resolution order: per-peer field wins → shorthand `memoryMode` → default `"hybrid"`.
|
||||
|
||||
### Effect on Honcho sync
|
||||
|
||||
- `userMemoryMode=local`: skip adding user peer messages to Honcho
|
||||
- `agentMemoryMode=local`: skip adding assistant peer messages to Honcho
|
||||
- Both local: skip `session.addMessages()` entirely
|
||||
- `userMemoryMode=honcho`: disable local USER.md writes
|
||||
- `agentMemoryMode=honcho`: disable local MEMORY.md / SOUL.md writes
|
||||
|
||||
---
|
||||
|
||||
## Spec: AI peer identity formation
|
||||
|
||||
### Problem
|
||||
|
||||
Honcho builds the user's representation organically by observing what the user says. The same mechanism exists for the AI peer — but only if `observe_me=True` is set for the agent peer. Without it, the agent peer accumulates nothing.
|
||||
|
||||
Additionally, existing persona files (SOUL.md, IDENTITY.md) should seed the AI peer's Honcho representation at first activation.
|
||||
|
||||
### Part A: observe_me=True for agent peer
|
||||
|
||||
```typescript
|
||||
await session.addPeers([
|
||||
[ownerPeer.id, { observeMe: true, observeOthers: false }],
|
||||
[agentPeer.id, { observeMe: true, observeOthers: true }], // was false
|
||||
]);
|
||||
```
|
||||
|
||||
One-line change. Foundational. Without it, the AI peer representation stays empty regardless of what the agent says.
|
||||
|
||||
### Part B: seedAiIdentity()
|
||||
|
||||
```typescript
|
||||
async function seedAiIdentity(
|
||||
agentPeer: Peer,
|
||||
content: string,
|
||||
source: string
|
||||
): Promise<boolean> {
|
||||
const wrapped = [
|
||||
`<ai_identity_seed>`,
|
||||
`<source>${source}</source>`,
|
||||
``,
|
||||
content.trim(),
|
||||
`</ai_identity_seed>`,
|
||||
].join("\n");
|
||||
|
||||
await agentPeer.addMessage("assistant", wrapped);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Part C: migrate agent files at setup
|
||||
|
||||
During `honcho setup`, upload agent-self files (SOUL.md, IDENTITY.md, AGENTS.md) to the agent peer via `seedAiIdentity()` instead of `session.uploadFile()`. This routes content through Honcho's observation pipeline.
|
||||
|
||||
### Part D: AI peer name in identity
|
||||
|
||||
When the agent has a configured name, prepend it to the injected system prompt:
|
||||
|
||||
```typescript
|
||||
const namePrefix = agentName ? `You are ${agentName}.\n\n` : "";
|
||||
return { systemPrompt: namePrefix + "## User Memory Context\n\n" + sections };
|
||||
```
|
||||
|
||||
### CLI surface
|
||||
|
||||
```
|
||||
honcho identity <file> # seed from file
|
||||
honcho identity --show # show current AI peer representation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec: session naming strategies
|
||||
|
||||
### Problem
|
||||
|
||||
A single global session means every project shares the same Honcho context. Per-directory sessions provide isolation without requiring users to name sessions manually.
|
||||
|
||||
### Strategies
|
||||
|
||||
| Strategy | Session key | When to use |
|
||||
|---|---|---|
|
||||
| `per-directory` | basename of CWD | Default. Each project gets its own session. |
|
||||
| `global` | fixed string `"global"` | Single cross-project session. |
|
||||
| manual map | user-configured per path | `sessions` config map overrides directory basename. |
|
||||
| title-based | sanitized session title | When agent supports named sessions set mid-conversation. |
|
||||
|
||||
### Config schema
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionStrategy": "per-directory",
|
||||
"sessionPeerPrefix": false,
|
||||
"sessions": {
|
||||
"/home/user/projects/foo": "foo-project"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI surface
|
||||
|
||||
```
|
||||
honcho sessions # list all mappings
|
||||
honcho map <name> # map cwd to session name
|
||||
honcho map # no-arg = list mappings
|
||||
```
|
||||
|
||||
Resolution order: manual map → session title → directory basename → platform key.
|
||||
|
||||
---
|
||||
|
||||
## Spec: CLI surface injection
|
||||
|
||||
### Problem
|
||||
|
||||
When a user asks "how do I change my memory settings?" the agent either hallucinates or says it doesn't know. The agent should know its own management interface.
|
||||
|
||||
### Pattern
|
||||
|
||||
When Honcho is active, append a compact command reference to the system prompt. Keep it under 300 chars.
|
||||
|
||||
```
|
||||
# Honcho memory integration
|
||||
Active. Session: {sessionKey}. Mode: {mode}.
|
||||
Management commands:
|
||||
honcho status — show config + connection
|
||||
honcho mode [hybrid|honcho|local] — show or set memory mode
|
||||
honcho sessions — list session mappings
|
||||
honcho map <name> — map directory to session
|
||||
honcho identity [file] [--show] — seed or show AI identity
|
||||
honcho setup — full interactive wizard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## openclaw-honcho checklist
|
||||
|
||||
Ordered by impact:
|
||||
|
||||
- [ ] **Async prefetch** — move `session.context()` out of `before_prompt_build` into post-`agent_end` background Promise
|
||||
- [ ] **observe_me=True for agent peer** — one-line change in `session.addPeers()`
|
||||
- [ ] **Dynamic reasoning level** — add helper; apply in `honcho_recall` and `honcho_analyze`; add `dialecticReasoningLevel` to config
|
||||
- [ ] **Per-peer memory modes** — add `userMemoryMode` / `agentMemoryMode` to config; gate Honcho sync and local writes
|
||||
- [ ] **seedAiIdentity()** — add helper; use during setup migration for SOUL.md / IDENTITY.md
|
||||
- [ ] **Session naming strategies** — add `sessionStrategy`, `sessions` map, `sessionPeerPrefix`
|
||||
- [ ] **CLI surface injection** — append command reference to `before_prompt_build` return value
|
||||
- [ ] **honcho identity subcommand** — seed from file or `--show` current representation
|
||||
- [ ] **AI peer name injection** — if `aiPeer` name configured, prepend to injected system prompt
|
||||
- [ ] **honcho mode / sessions / map** — CLI parity with Hermes
|
||||
|
||||
Already done in openclaw-honcho (do not re-implement): `lastSavedIndex` dedup, platform metadata stripping, multi-agent parent observer, `peerPerspective` on `context()`, tiered tool surface, workspace `agentPeerMap`, QMD passthrough, self-hosted Honcho.
|
||||
|
||||
---
|
||||
|
||||
## nanobot-honcho checklist
|
||||
|
||||
Greenfield integration. Start from openclaw-honcho's architecture and apply all Hermes patterns from day one.
|
||||
|
||||
### Phase 1 — core correctness
|
||||
|
||||
- [ ] Dual peer model (owner + agent peer), both with `observe_me=True`
|
||||
- [ ] Message capture at turn end with `lastSavedIndex` dedup
|
||||
- [ ] Platform metadata stripping before Honcho storage
|
||||
- [ ] Async prefetch from day one — do not implement blocking context injection
|
||||
- [ ] Legacy file migration at first activation (USER.md → owner peer, SOUL.md → `seedAiIdentity()`)
|
||||
|
||||
### Phase 2 — configuration
|
||||
|
||||
- [ ] Config schema: `apiKey`, `workspaceId`, `baseUrl`, `memoryMode`, `userMemoryMode`, `agentMemoryMode`, `dialecticReasoningLevel`, `sessionStrategy`, `sessions`
|
||||
- [ ] Per-peer memory mode gating
|
||||
- [ ] Dynamic reasoning level
|
||||
- [ ] Session naming strategies
|
||||
|
||||
### Phase 3 — tools and CLI
|
||||
|
||||
- [ ] Tool surface: `honcho_profile`, `honcho_recall`, `honcho_analyze`, `honcho_search`, `honcho_context`
|
||||
- [ ] CLI: `setup`, `status`, `sessions`, `map`, `mode`, `identity`
|
||||
- [ ] CLI surface injection into system prompt
|
||||
- [ ] AI peer name wired into agent identity
|
||||
@@ -1,142 +0,0 @@
|
||||
# Migrating from OpenClaw to Hermes Agent
|
||||
|
||||
This guide covers how to import your OpenClaw settings, memories, skills, and API keys into Hermes Agent.
|
||||
|
||||
## Three Ways to Migrate
|
||||
|
||||
### 1. Automatic (during first-time setup)
|
||||
|
||||
When you run `hermes setup` for the first time and Hermes detects `~/.openclaw`, it automatically offers to import your OpenClaw data before configuration begins. Just accept the prompt and everything is handled for you.
|
||||
|
||||
### 2. CLI Command (quick, scriptable)
|
||||
|
||||
```bash
|
||||
hermes claw migrate # Preview then migrate (always shows preview first)
|
||||
hermes claw migrate --dry-run # Preview only, no changes
|
||||
hermes claw migrate --preset user-data # Migrate without API keys/secrets
|
||||
hermes claw migrate --yes # Skip confirmation prompt
|
||||
```
|
||||
|
||||
The migration always shows a full preview of what will be imported before making any changes. You review the preview and confirm before anything is written.
|
||||
|
||||
**All options:**
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--source PATH` | Path to OpenClaw directory (default: `~/.openclaw`) |
|
||||
| `--dry-run` | Preview only — no files are modified |
|
||||
| `--preset {user-data,full}` | Migration preset (default: `full`). `user-data` excludes secrets |
|
||||
| `--overwrite` | Overwrite existing files (default: skip conflicts) |
|
||||
| `--migrate-secrets` | Include allowlisted secrets (auto-enabled with `full` preset) |
|
||||
| `--workspace-target PATH` | Copy workspace instructions (AGENTS.md) to this absolute path |
|
||||
| `--skill-conflict {skip,overwrite,rename}` | How to handle skill name conflicts (default: `skip`) |
|
||||
| `--yes`, `-y` | Skip confirmation prompts |
|
||||
|
||||
### 3. Agent-Guided (interactive, with previews)
|
||||
|
||||
Ask the agent to run the migration for you:
|
||||
|
||||
```
|
||||
> Migrate my OpenClaw setup to Hermes
|
||||
```
|
||||
|
||||
The agent will use the `openclaw-migration` skill to:
|
||||
1. Run a preview first to show what would change
|
||||
2. Ask about conflict resolution (SOUL.md, skills, etc.)
|
||||
3. Let you choose between `user-data` and `full` presets
|
||||
4. Execute the migration with your choices
|
||||
5. Print a detailed summary of what was migrated
|
||||
|
||||
## What Gets Migrated
|
||||
|
||||
### `user-data` preset
|
||||
| Item | Source | Destination |
|
||||
|------|--------|-------------|
|
||||
| SOUL.md | `~/.openclaw/workspace/SOUL.md` | `~/.hermes/SOUL.md` |
|
||||
| Memory entries | `~/.openclaw/workspace/MEMORY.md` | `~/.hermes/memories/MEMORY.md` |
|
||||
| User profile | `~/.openclaw/workspace/USER.md` | `~/.hermes/memories/USER.md` |
|
||||
| Skills | `~/.openclaw/workspace/skills/` | `~/.hermes/skills/openclaw-imports/` |
|
||||
| Command allowlist | `~/.openclaw/workspace/exec_approval_patterns.yaml` | Merged into `~/.hermes/config.yaml` |
|
||||
| Messaging settings | `~/.openclaw/config.yaml` (TELEGRAM_ALLOWED_USERS, MESSAGING_CWD) | `~/.hermes/.env` |
|
||||
| TTS assets | `~/.openclaw/workspace/tts/` | `~/.hermes/tts/` |
|
||||
|
||||
Workspace files are also checked at `workspace.default/` and `workspace-main/` as fallback paths (OpenClaw renamed `workspace/` to `workspace-main/` in recent versions).
|
||||
|
||||
### `full` preset (adds to `user-data`)
|
||||
| Item | Source | Destination |
|
||||
|------|--------|-------------|
|
||||
| Telegram bot token | `openclaw.json` channels config | `~/.hermes/.env` |
|
||||
| OpenRouter API key | `.env`, `openclaw.json`, or `openclaw.json["env"]` | `~/.hermes/.env` |
|
||||
| OpenAI API key | `.env`, `openclaw.json`, or `openclaw.json["env"]` | `~/.hermes/.env` |
|
||||
| Anthropic API key | `.env`, `openclaw.json`, or `openclaw.json["env"]` | `~/.hermes/.env` |
|
||||
| ElevenLabs API key | `.env`, `openclaw.json`, or `openclaw.json["env"]` | `~/.hermes/.env` |
|
||||
|
||||
API keys are searched across four sources: inline config values, `~/.openclaw/.env`, the `openclaw.json` `"env"` sub-object, and per-agent auth profiles.
|
||||
|
||||
Only allowlisted secrets are ever imported. Other credentials are skipped and reported.
|
||||
|
||||
## OpenClaw Schema Compatibility
|
||||
|
||||
The migration handles both old and current OpenClaw config layouts:
|
||||
|
||||
- **Channel tokens**: Reads from flat paths (`channels.telegram.botToken`) and the newer `accounts.default` layout (`channels.telegram.accounts.default.botToken`)
|
||||
- **TTS provider**: OpenClaw renamed "edge" to "microsoft" — both are recognized and mapped to Hermes' "edge"
|
||||
- **Provider API types**: Both short (`openai`, `anthropic`) and hyphenated (`openai-completions`, `anthropic-messages`, `google-generative-ai`) values are mapped correctly
|
||||
- **thinkingDefault**: All enum values are handled including newer ones (`minimal`, `xhigh`, `adaptive`)
|
||||
- **Matrix**: Uses `accessToken` field (not `botToken`)
|
||||
- **SecretRef formats**: Plain strings, env templates (`${VAR}`), and `source: "env"` SecretRefs are resolved. `source: "file"` and `source: "exec"` SecretRefs produce a warning — add those keys manually after migration.
|
||||
|
||||
## Conflict Handling
|
||||
|
||||
By default, the migration **will not overwrite** existing Hermes data:
|
||||
|
||||
- **SOUL.md** — skipped if one already exists in `~/.hermes/`
|
||||
- **Memory entries** — skipped if memories already exist (to avoid duplicates)
|
||||
- **Skills** — skipped if a skill with the same name already exists
|
||||
- **API keys** — skipped if the key is already set in `~/.hermes/.env`
|
||||
|
||||
To overwrite conflicts, use `--overwrite`. The migration creates backups before overwriting.
|
||||
|
||||
For skills, you can also use `--skill-conflict rename` to import conflicting skills under a new name (e.g., `skill-name-imported`).
|
||||
|
||||
## Migration Report
|
||||
|
||||
Every migration produces a report showing:
|
||||
- **Migrated items** — what was successfully imported
|
||||
- **Conflicts** — items skipped because they already exist
|
||||
- **Skipped items** — items not found in the source
|
||||
- **Errors** — items that failed to import
|
||||
|
||||
For executed migrations, the full report is saved to `~/.hermes/migration/openclaw/<timestamp>/`.
|
||||
|
||||
## Post-Migration Notes
|
||||
|
||||
- **Skills require a new session** — imported skills take effect after restarting your agent or starting a new chat.
|
||||
- **WhatsApp requires re-pairing** — WhatsApp uses QR-code pairing, not token-based auth. Run `hermes whatsapp` to pair.
|
||||
- **Archive cleanup** — after migration, you'll be offered to rename `~/.openclaw/` to `.openclaw.pre-migration/` to prevent state confusion. You can also run `hermes claw cleanup` later.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "OpenClaw directory not found"
|
||||
The migration looks for `~/.openclaw` by default, then tries `~/.clawdbot` and `~/.moltbot`. If your OpenClaw is installed elsewhere, use `--source`:
|
||||
```bash
|
||||
hermes claw migrate --source /path/to/.openclaw
|
||||
```
|
||||
|
||||
### "Migration script not found"
|
||||
The migration script ships with Hermes Agent. If you installed via pip (not git clone), the `optional-skills/` directory may not be present. Install the skill from the Skills Hub:
|
||||
```bash
|
||||
hermes skills install openclaw-migration
|
||||
```
|
||||
|
||||
### Memory overflow
|
||||
If your OpenClaw MEMORY.md or USER.md exceeds Hermes' character limits, excess entries are exported to an overflow file in the migration report directory. You can manually review and add the most important ones.
|
||||
|
||||
### API keys not found
|
||||
Keys might be stored in different places depending on your OpenClaw setup:
|
||||
- `~/.openclaw/.env` file
|
||||
- Inline in `openclaw.json` under `models.providers.*.apiKey`
|
||||
- In `openclaw.json` under the `"env"` or `"env.vars"` sub-objects
|
||||
- In `~/.openclaw/agents/main/agent/auth-profiles.json`
|
||||
|
||||
The migration checks all four. If keys use `source: "file"` or `source: "exec"` SecretRefs, they can't be resolved automatically — add them via `hermes config set`.
|
||||
@@ -1,108 +0,0 @@
|
||||
# Ink Gateway TUI Migration — Post-mortem
|
||||
|
||||
Planned: 2026-04-01 · Delivered: 2026-04 · Status: shipped, classic (prompt_toolkit) CLI still present
|
||||
|
||||
## What Shipped
|
||||
|
||||
Three layers, same repo, Python runtime unchanged.
|
||||
|
||||
```
|
||||
ui-tui (Node/TS) ──stdio JSON-RPC──▶ tui_gateway (Py) ──▶ AIAgent (run_agent.py)
|
||||
```
|
||||
|
||||
### Backend — `tui_gateway/`
|
||||
|
||||
```
|
||||
tui_gateway/
|
||||
├── entry.py # subprocess entrypoint, stdio read/write loop
|
||||
├── server.py # everything: sessions dict, @method handlers, _emit
|
||||
├── render.py # stream renderer, diff rendering, message rendering
|
||||
├── slash_worker.py # subprocess that runs hermes_cli slash commands
|
||||
└── __init__.py
|
||||
```
|
||||
|
||||
`server.py` owns the full runtime-control surface: session store (`_sessions: dict[str, dict]`), method registry (`@method("…")` decorator), event emitter (`_emit`), agent lifecycle (`_make_agent`, `_init_session`, `_wire_callbacks`), approval/sudo/clarify round-trips, and JSON-RPC dispatch.
|
||||
|
||||
Protocol methods (`@method(...)` in `server.py`):
|
||||
|
||||
- session: `session.{create, resume, list, close, interrupt, usage, history, compress, branch, title, save, undo}`
|
||||
- prompt: `prompt.{submit, background, btw}`
|
||||
- tools: `tools.{list, show, configure}`
|
||||
- slash: `slash.exec`, `command.{dispatch, resolve}`, `commands.catalog`, `complete.{path, slash}`
|
||||
- approvals: `approval.respond`, `sudo.respond`, `clarify.respond`, `secret.respond`
|
||||
- config/state: `config.{get, set, show}`, `model.options`, `reload.mcp`
|
||||
- ops: `shell.exec`, `cli.exec`, `terminal.resize`, `input.detect_drop`, `clipboard.paste`, `paste.collapse`, `image.attach`, `process.stop`
|
||||
- misc: `agents.list`, `skills.manage`, `plugins.list`, `cron.manage`, `insights.get`, `rollback.{list, diff, restore}`, `browser.manage`
|
||||
|
||||
Protocol events (`_emit(…)` → handled in `ui-tui/src/app/createGatewayEventHandler.ts`):
|
||||
|
||||
- lifecycle: `gateway.{ready, stderr}`, `session.info`, `skin.changed`
|
||||
- stream: `message.{start, delta, complete}`, `thinking.delta`, `reasoning.{delta, available}`, `status.update`
|
||||
- tools: `tool.{start, progress, complete, generating}`, `subagent.{start, thinking, tool, progress, complete}`
|
||||
- interactive: `approval.request`, `sudo.request`, `clarify.request`, `secret.request`
|
||||
- async: `background.complete`, `btw.complete`, `error`
|
||||
|
||||
### Frontend — `ui-tui/src/`
|
||||
|
||||
```
|
||||
src/
|
||||
├── entry.tsx # node bootstrap: bootBanner → spawn python → dynamic-import Ink → render(<App/>)
|
||||
├── app.tsx # <GatewayProvider> wraps <AppLayout>
|
||||
├── bootBanner.ts # raw-ANSI banner to stdout in ~2ms, pre-React
|
||||
├── gatewayClient.ts # JSON-RPC client over child_process stdio
|
||||
├── gatewayTypes.ts # typed RPC responses + GatewayEvent union
|
||||
├── theme.ts # DEFAULT_THEME + fromSkin
|
||||
│
|
||||
├── app/ # hooks + stores — the orchestration layer
|
||||
│ ├── uiStore.ts # nanostore: sid, info, busy, usage, theme, status…
|
||||
│ ├── turnStore.ts # nanostore: per-turn activity / reasoning / tools
|
||||
│ ├── turnController.ts # imperative singleton for stream-time operations
|
||||
│ ├── overlayStore.ts # nanostore: modal/overlay state
|
||||
│ ├── useMainApp.ts # top-level composition hook
|
||||
│ ├── useSessionLifecycle.ts # session.create/resume/close/reset
|
||||
│ ├── useSubmission.ts # shell/slash/prompt dispatch + interpolation
|
||||
│ ├── useConfigSync.ts # config.get + mtime poll
|
||||
│ ├── useComposerState.ts # input buffer, paste snippets, editor mode
|
||||
│ ├── useInputHandlers.ts # key bindings
|
||||
│ ├── createGatewayEventHandler.ts # event-stream dispatcher
|
||||
│ ├── createSlashHandler.ts # slash command router (registry + python fallback)
|
||||
│ └── slash/commands/ # core.ts, ops.ts, session.ts — TS-owned slash commands
|
||||
│
|
||||
├── components/ # AppLayout, AppChrome, AppOverlays, MessageLine, Thinking, Markdown, pickers, prompts, Banner, SessionPanel
|
||||
├── config/ # env, limits, timing constants
|
||||
├── content/ # charms, faces, fortunes, hotkeys, placeholders, verbs
|
||||
├── domain/ # details, messages, paths, roles, slash, usage, viewport
|
||||
├── protocol/ # interpolation, paste regex
|
||||
├── hooks/ # useCompletion, useInputHistory, useQueue, useVirtualHistory
|
||||
└── lib/ # history, messages, osc52, rpc, text
|
||||
```
|
||||
|
||||
### CLI entry points — `hermes_cli/main.py`
|
||||
|
||||
- `hermes --tui` → `node dist/entry.js` (auto-builds when `.ts`/`.tsx` newer than `dist/entry.js`)
|
||||
- `hermes --tui --dev` → `tsx src/entry.tsx` (skip build)
|
||||
- `HERMES_TUI_DIR=…` → external prebuilt dist (nix, distro packaging)
|
||||
|
||||
## Diverged From Original Plan
|
||||
|
||||
| Plan | Reality | Why |
|
||||
|---|---|---|
|
||||
| `tui_gateway/{controller,session_state,events,protocol}.py` | all collapsed into `server.py` | no second consumer ever emerged, keeping one file cheaper than four |
|
||||
| `ui-tui/src/main.tsx` | split into `entry.tsx` (bootstrap) + `app.tsx` (shell) | boot banner + early python spawn wanted a pre-React moment |
|
||||
| `ui-tui/src/state/store.ts` | three nanostores (`uiStore`, `turnStore`, `overlayStore`) | separate lifetimes: ui persists, turn resets per reply, overlay is modal |
|
||||
| `approval.requested` / `sudo.requested` / `clarify.requested` | `*.request` (no `-ed`) | cosmetic |
|
||||
| `session.cancel` | dropped | `session.interrupt` covers it |
|
||||
| `HERMES_EXPERIMENTAL_TUI=1`, `display.experimental_tui: true`, `/tui on/off/status` | none shipped | `--tui` went from opt-in to first-class without an experimental phase |
|
||||
|
||||
## Post-migration Additions (not in original plan)
|
||||
|
||||
- **Async `session.create`** — returns sid in ~1ms, agent builds on a background thread, `session.info` broadcasts when ready; `_wait_agent()` gates every agent-touching handler via `_sess`
|
||||
- **`bootBanner`** — raw-ANSI logo painted to stdout at T≈2ms, before Ink loads; `<AlternateScreen>` wipes it seamlessly when React mounts
|
||||
- **Selection uniform bg** — `theme.color.selectionBg` wired via `useSelection().setSelectionBgColor`; replaces SGR-inverse per-cell swap that fragmented over amber/gold fg
|
||||
- **Slash command registry** — TS-owned commands in `app/slash/commands/{core,ops,session}.ts`, everything else falls through to `slash.exec` (python worker)
|
||||
- **Turn store + controller split** — imperative singleton (`turnController`) holds refs/timers, nanostore (`turnStore`) holds render-visible state
|
||||
|
||||
## What's Still Open
|
||||
|
||||
- **Classic CLI not deleted.** `cli.py` still has ~80 `prompt_toolkit` references; classic REPL is still the default when `--tui` is absent. The original plan's "Cut 4 · prompt_toolkit removal later" hasn't happened.
|
||||
- **No config-file opt-in.** `HERMES_EXPERIMENTAL_TUI` and `display.experimental_tui` were never built; only the CLI flag exists. Fine for now — if we want "default to TUI", a single line in `main.py` flips it.
|
||||
@@ -1,106 +0,0 @@
|
||||
# ============================================================================
|
||||
# Hermes Agent — Example Skin Template
|
||||
# ============================================================================
|
||||
#
|
||||
# Copy this file to ~/.hermes/skins/<name>.yaml to create a custom skin.
|
||||
# All fields are optional — missing values inherit from the default skin.
|
||||
# Activate with: /skin <name> or display.skin: <name> in config.yaml
|
||||
#
|
||||
# Keys are marked:
|
||||
# (both) — applies to both the classic CLI and the TUI
|
||||
# (classic) — classic CLI only (see hermes --tui in user-guide/tui.md)
|
||||
# (tui) — TUI only
|
||||
#
|
||||
# See hermes_cli/skin_engine.py for the full schema reference.
|
||||
# ============================================================================
|
||||
|
||||
# Required: unique skin name (used in /skin command and config)
|
||||
name: example
|
||||
description: An example custom skin — copy and modify this template
|
||||
|
||||
# ── Colors ──────────────────────────────────────────────────────────────────
|
||||
# Hex color values. These control the visual palette.
|
||||
colors:
|
||||
# Banner panel (the startup welcome box) — (both)
|
||||
banner_border: "#CD7F32" # Panel border
|
||||
banner_title: "#FFD700" # Panel title text
|
||||
banner_accent: "#FFBF00" # Section headers (Available Tools, Skills, etc.)
|
||||
banner_dim: "#B8860B" # Dim/muted text (separators, model info)
|
||||
banner_text: "#FFF8DC" # Body text (tool names, skill names)
|
||||
|
||||
# UI elements — (both)
|
||||
ui_accent: "#FFBF00" # General accent (falls back to banner_accent)
|
||||
ui_label: "#4dd0e1" # Labels
|
||||
ui_ok: "#4caf50" # Success indicators
|
||||
ui_error: "#ef5350" # Error indicators
|
||||
ui_warn: "#ffa726" # Warning indicators
|
||||
|
||||
# Input area
|
||||
prompt: "#FFF8DC" # Prompt text / `❯` glyph color (both)
|
||||
input_rule: "#CD7F32" # Horizontal rule above input (classic)
|
||||
|
||||
# Response box — (classic)
|
||||
response_border: "#FFD700" # Response box border
|
||||
|
||||
# Session display — (both)
|
||||
session_label: "#DAA520" # "Session: " label
|
||||
session_border: "#8B8682" # Session ID text
|
||||
|
||||
# TUI / CLI surfaces — (classic: status bar, voice badge, completion meta)
|
||||
status_bar_bg: "#1a1a2e" # Status / usage bar background (classic)
|
||||
voice_status_bg: "#1a1a2e" # Voice-mode badge background (classic)
|
||||
completion_menu_bg: "#1a1a2e" # Completion list background (both)
|
||||
completion_menu_current_bg: "#333355" # Active completion row background (both)
|
||||
completion_menu_meta_bg: "#1a1a2e" # Completion meta column bg (classic)
|
||||
completion_menu_meta_current_bg: "#333355" # Active meta bg (classic)
|
||||
|
||||
# Drag-to-select background — (tui)
|
||||
selection_bg: "#3a3a55" # Uniform selection highlight in the TUI
|
||||
|
||||
# ── Spinner ─────────────────────────────────────────────────────────────────
|
||||
# (classic) — the TUI uses its own animated indicators; spinner config here
|
||||
# is only read by the classic prompt_toolkit CLI.
|
||||
spinner:
|
||||
# Faces shown while waiting for the API response
|
||||
waiting_faces:
|
||||
- "(。◕‿◕。)"
|
||||
- "(◕‿◕✿)"
|
||||
- "٩(◕‿◕。)۶"
|
||||
|
||||
# Faces shown during extended thinking/reasoning
|
||||
thinking_faces:
|
||||
- "(。•́︿•̀。)"
|
||||
- "(◔_◔)"
|
||||
- "(¬‿¬)"
|
||||
|
||||
# Verbs used in spinner messages (e.g., "pondering your request...")
|
||||
thinking_verbs:
|
||||
- "pondering"
|
||||
- "contemplating"
|
||||
- "musing"
|
||||
- "ruminating"
|
||||
|
||||
# Optional: left/right decorations around the spinner
|
||||
# Each entry is a [left, right] pair. Omit entirely for no wings.
|
||||
# wings:
|
||||
# - ["⟪⚔", "⚔⟫"]
|
||||
# - ["⟪▲", "▲⟫"]
|
||||
|
||||
# ── Branding ────────────────────────────────────────────────────────────────
|
||||
# Text strings used throughout the interface.
|
||||
branding:
|
||||
agent_name: "Hermes Agent" # (both) Banner title, about display
|
||||
welcome: "Welcome! Type your message or /help for commands." # (both)
|
||||
goodbye: "Goodbye! ⚕" # (both) Exit message
|
||||
response_label: " ⚕ Hermes " # (classic) Response box header label
|
||||
prompt_symbol: "❯ " # (both) Input prompt glyph
|
||||
help_header: "(^_^)? Available Commands" # (both) /help overlay title
|
||||
|
||||
# ── Tool Output ─────────────────────────────────────────────────────────────
|
||||
# Character used as the prefix for tool output lines. (both)
|
||||
# Default is "┊" (thin dotted vertical line). Some alternatives:
|
||||
# "╎" (light triple dash vertical)
|
||||
# "▏" (left one-eighth block)
|
||||
# "│" (box drawing light vertical)
|
||||
# "┃" (box drawing heavy vertical)
|
||||
tool_prefix: "┊"
|
||||
@@ -1081,6 +1081,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
content: str,
|
||||
*,
|
||||
finalize: bool = False,
|
||||
) -> SendResult:
|
||||
"""Edit a previously sent Discord message."""
|
||||
if not self._client:
|
||||
|
||||
+118
-21
@@ -8,7 +8,8 @@ Supports:
|
||||
- Gateway allowlist integration via FEISHU_ALLOWED_USERS
|
||||
- Persistent dedup state across restarts
|
||||
- Per-chat serial message processing (matches openclaw createChatQueue)
|
||||
- Persistent ACK emoji reaction on inbound messages
|
||||
- Processing status reactions: Typing while working, removed on success,
|
||||
swapped for CrossMark on failure
|
||||
- Reaction events routed as synthetic text events (matches openclaw)
|
||||
- Interactive card button-click events routed as synthetic COMMAND events
|
||||
- Webhook anomaly tracking (matches openclaw createWebhookAnomalyTracker)
|
||||
@@ -29,6 +30,7 @@ import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -98,6 +100,7 @@ from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
ProcessingOutcome,
|
||||
SendResult,
|
||||
SUPPORTED_DOCUMENT_TYPES,
|
||||
cache_document_from_bytes,
|
||||
@@ -190,7 +193,17 @@ _APPROVAL_LABEL_MAP: Dict[str, str] = {
|
||||
}
|
||||
_FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs
|
||||
_FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback
|
||||
_FEISHU_ACK_EMOJI = "OK"
|
||||
|
||||
# Feishu reactions render as prominent badges, unlike Discord/Telegram's
|
||||
# small footer emoji — a success badge on every message would add noise, so
|
||||
# we only mark start (Typing) and failure (CrossMark); the reply itself is
|
||||
# the success signal.
|
||||
_FEISHU_REACTION_IN_PROGRESS = "Typing"
|
||||
_FEISHU_REACTION_FAILURE = "CrossMark"
|
||||
# Bound on the (message_id → reaction_id) handle cache. Happy-path entries
|
||||
# drain on completion; the cap is a safeguard against unbounded growth from
|
||||
# delete-failures, not a capacity plan.
|
||||
_FEISHU_PROCESSING_REACTION_CACHE_SIZE = 1024
|
||||
|
||||
# QR onboarding constants
|
||||
_ONBOARD_ACCOUNTS_URLS = {
|
||||
@@ -1141,6 +1154,9 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
# Exec approval button state (approval_id → {session_key, message_id, chat_id})
|
||||
self._approval_state: Dict[int, Dict[str, str]] = {}
|
||||
self._approval_counter = itertools.count(1)
|
||||
# Feishu reaction deletion requires the opaque reaction_id returned
|
||||
# by create, so we cache it per message_id.
|
||||
self._pending_processing_reactions: "OrderedDict[str, str]" = OrderedDict()
|
||||
self._load_seen_message_ids()
|
||||
|
||||
@staticmethod
|
||||
@@ -1468,6 +1484,8 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
content: str,
|
||||
*,
|
||||
finalize: bool = False,
|
||||
) -> SendResult:
|
||||
"""Edit a previously sent Feishu text/post message."""
|
||||
if not self._client:
|
||||
@@ -2048,12 +2066,12 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
operator_type,
|
||||
emoji_type,
|
||||
)
|
||||
# Only process reactions from real users. Ignore app/bot-generated reactions
|
||||
# and Hermes' own ACK emoji to avoid feedback loops.
|
||||
# Drop bot/app-origin reactions to break the feedback loop from our
|
||||
# own lifecycle reactions. A human reacting with the same emoji (e.g.
|
||||
# clicking Typing on a bot message) is still routed through.
|
||||
loop = self._loop
|
||||
if (
|
||||
operator_type in {"bot", "app"}
|
||||
or emoji_type == _FEISHU_ACK_EMOJI
|
||||
or not message_id
|
||||
or loop is None
|
||||
or bool(getattr(loop, "is_closed", lambda: False)())
|
||||
@@ -2277,33 +2295,35 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
|
||||
async def _handle_message_with_guards(self, event: MessageEvent) -> None:
|
||||
"""Dispatch a single event through the agent pipeline with per-chat serialization
|
||||
and a persistent ACK emoji reaction before processing starts.
|
||||
before handing the event off to the agent.
|
||||
|
||||
- Per-chat lock: ensures messages in the same chat are processed one at a time
|
||||
(matches openclaw's createChatQueue serial queue behaviour).
|
||||
- ACK indicator: adds a CHECK reaction to the triggering message before handing
|
||||
off to the agent and leaves it in place as a receipt marker.
|
||||
Per-chat lock ensures messages in the same chat are processed one at a
|
||||
time (matches openclaw's createChatQueue serial queue behaviour).
|
||||
"""
|
||||
chat_id = getattr(event.source, "chat_id", "") or "" if event.source else ""
|
||||
chat_lock = self._get_chat_lock(chat_id)
|
||||
async with chat_lock:
|
||||
message_id = event.message_id
|
||||
if message_id:
|
||||
await self._add_ack_reaction(message_id)
|
||||
await self.handle_message(event)
|
||||
|
||||
async def _add_ack_reaction(self, message_id: str) -> Optional[str]:
|
||||
"""Add a persistent ACK emoji reaction to signal the message was received."""
|
||||
if not self._client or not message_id:
|
||||
# =========================================================================
|
||||
# Processing status reactions
|
||||
# =========================================================================
|
||||
|
||||
def _reactions_enabled(self) -> bool:
|
||||
return os.getenv("FEISHU_REACTIONS", "true").strip().lower() not in ("false", "0", "no")
|
||||
|
||||
async def _add_reaction(self, message_id: str, emoji_type: str) -> Optional[str]:
|
||||
"""Return the reaction_id on success, else None. The id is needed later for deletion."""
|
||||
if not self._client or not message_id or not emoji_type:
|
||||
return None
|
||||
try:
|
||||
from lark_oapi.api.im.v1 import ( # lazy import — keeps optional dep optional
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateMessageReactionRequest,
|
||||
CreateMessageReactionRequestBody,
|
||||
)
|
||||
body = (
|
||||
CreateMessageReactionRequestBody.builder()
|
||||
.reaction_type({"emoji_type": _FEISHU_ACK_EMOJI})
|
||||
.reaction_type({"emoji_type": emoji_type})
|
||||
.build()
|
||||
)
|
||||
request = (
|
||||
@@ -2316,16 +2336,93 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
if response and getattr(response, "success", lambda: False)():
|
||||
data = getattr(response, "data", None)
|
||||
return getattr(data, "reaction_id", None)
|
||||
logger.warning(
|
||||
"[Feishu] Failed to add ack reaction to %s: code=%s msg=%s",
|
||||
logger.debug(
|
||||
"[Feishu] Add reaction %s on %s rejected: code=%s msg=%s",
|
||||
emoji_type,
|
||||
message_id,
|
||||
getattr(response, "code", None),
|
||||
getattr(response, "msg", None),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("[Feishu] Failed to add ack reaction to %s", message_id, exc_info=True)
|
||||
logger.warning(
|
||||
"[Feishu] Add reaction %s on %s raised",
|
||||
emoji_type,
|
||||
message_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
async def _remove_reaction(self, message_id: str, reaction_id: str) -> bool:
|
||||
if not self._client or not message_id or not reaction_id:
|
||||
return False
|
||||
try:
|
||||
from lark_oapi.api.im.v1 import DeleteMessageReactionRequest
|
||||
request = (
|
||||
DeleteMessageReactionRequest.builder()
|
||||
.message_id(message_id)
|
||||
.reaction_id(reaction_id)
|
||||
.build()
|
||||
)
|
||||
response = await asyncio.to_thread(self._client.im.v1.message_reaction.delete, request)
|
||||
if response and getattr(response, "success", lambda: False)():
|
||||
return True
|
||||
logger.debug(
|
||||
"[Feishu] Remove reaction %s on %s rejected: code=%s msg=%s",
|
||||
reaction_id,
|
||||
message_id,
|
||||
getattr(response, "code", None),
|
||||
getattr(response, "msg", None),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[Feishu] Remove reaction %s on %s raised",
|
||||
reaction_id,
|
||||
message_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
def _remember_processing_reaction(self, message_id: str, reaction_id: str) -> None:
|
||||
cache = self._pending_processing_reactions
|
||||
cache[message_id] = reaction_id
|
||||
cache.move_to_end(message_id)
|
||||
while len(cache) > _FEISHU_PROCESSING_REACTION_CACHE_SIZE:
|
||||
cache.popitem(last=False)
|
||||
|
||||
def _pop_processing_reaction(self, message_id: str) -> Optional[str]:
|
||||
return self._pending_processing_reactions.pop(message_id, None)
|
||||
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
if not self._reactions_enabled():
|
||||
return
|
||||
message_id = event.message_id
|
||||
if not message_id or message_id in self._pending_processing_reactions:
|
||||
return
|
||||
reaction_id = await self._add_reaction(message_id, _FEISHU_REACTION_IN_PROGRESS)
|
||||
if reaction_id:
|
||||
self._remember_processing_reaction(message_id, reaction_id)
|
||||
|
||||
async def on_processing_complete(
|
||||
self, event: MessageEvent, outcome: ProcessingOutcome
|
||||
) -> None:
|
||||
if not self._reactions_enabled():
|
||||
return
|
||||
message_id = event.message_id
|
||||
if not message_id:
|
||||
return
|
||||
|
||||
start_reaction_id = self._pending_processing_reactions.get(message_id)
|
||||
if start_reaction_id:
|
||||
if not await self._remove_reaction(message_id, start_reaction_id):
|
||||
# Don't stack a second badge on top of a Typing we couldn't
|
||||
# remove — UI would read as both "working" and "done/failed"
|
||||
# simultaneously. Keep the handle so LRU eventually evicts it.
|
||||
return
|
||||
self._pop_processing_reaction(message_id)
|
||||
|
||||
if outcome is ProcessingOutcome.FAILURE:
|
||||
await self._add_reaction(message_id, _FEISHU_REACTION_FAILURE)
|
||||
|
||||
# =========================================================================
|
||||
# Webhook server and security
|
||||
# =========================================================================
|
||||
|
||||
@@ -825,7 +825,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
|
||||
|
||||
async def edit_message(
|
||||
self, chat_id: str, message_id: str, content: str
|
||||
self, chat_id: str, message_id: str, content: str, *, finalize: bool = False
|
||||
) -> SendResult:
|
||||
"""Edit an existing message (via m.replace)."""
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ class MattermostAdapter(BasePlatformAdapter):
|
||||
)
|
||||
|
||||
async def edit_message(
|
||||
self, chat_id: str, message_id: str, content: str
|
||||
self, chat_id: str, message_id: str, content: str, *, finalize: bool = False
|
||||
) -> SendResult:
|
||||
"""Edit an existing post."""
|
||||
formatted = self.format_message(content)
|
||||
|
||||
+114
-18
@@ -18,6 +18,7 @@ import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
@@ -127,6 +128,27 @@ def _render_mentions(text: str, mentions: list) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _is_signal_service_id(value: str) -> bool:
|
||||
"""Return True if *value* already looks like a Signal service identifier."""
|
||||
if not value:
|
||||
return False
|
||||
if value.startswith("PNI:") or value.startswith("u:"):
|
||||
return True
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
return True
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _looks_like_e164_number(value: str) -> bool:
|
||||
"""Return True for a plausible E.164 phone number."""
|
||||
if not value or not value.startswith("+"):
|
||||
return False
|
||||
digits = value[1:]
|
||||
return digits.isdigit() and 7 <= len(digits) <= 15
|
||||
|
||||
|
||||
def check_signal_requirements() -> bool:
|
||||
"""Check if Signal is configured (has URL and account)."""
|
||||
return bool(os.getenv("SIGNAL_HTTP_URL") and os.getenv("SIGNAL_ACCOUNT"))
|
||||
@@ -179,6 +201,12 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
# in Note to Self / self-chat mode (mirrors WhatsApp recentlySentIds)
|
||||
self._recent_sent_timestamps: set = set()
|
||||
self._max_recent_timestamps = 50
|
||||
# Signal increasingly exposes ACI/PNI UUIDs as stable recipient IDs.
|
||||
# Keep a best-effort mapping so outbound sends can upgrade from a
|
||||
# phone number to the corresponding UUID when signal-cli prefers it.
|
||||
self._recipient_uuid_by_number: Dict[str, str] = {}
|
||||
self._recipient_number_by_uuid: Dict[str, str] = {}
|
||||
self._recipient_cache_lock = asyncio.Lock()
|
||||
|
||||
logger.info("Signal adapter initialized: url=%s account=%s groups=%s",
|
||||
self.http_url, redact_phone(self.account),
|
||||
@@ -195,31 +223,40 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
# Acquire scoped lock to prevent duplicate Signal listeners for the same phone
|
||||
lock_acquired = False
|
||||
try:
|
||||
if not self._acquire_platform_lock('signal-phone', self.account, 'Signal account'):
|
||||
return False
|
||||
lock_acquired = True
|
||||
except Exception as e:
|
||||
logger.warning("Signal: Could not acquire phone lock (non-fatal): %s", e)
|
||||
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
# Health check — verify signal-cli daemon is reachable
|
||||
try:
|
||||
resp = await self.client.get(f"{self.http_url}/api/v1/check", timeout=10.0)
|
||||
if resp.status_code != 200:
|
||||
logger.error("Signal: health check failed (status %d)", resp.status_code)
|
||||
# Health check — verify signal-cli daemon is reachable
|
||||
try:
|
||||
resp = await self.client.get(f"{self.http_url}/api/v1/check", timeout=10.0)
|
||||
if resp.status_code != 200:
|
||||
logger.error("Signal: health check failed (status %d)", resp.status_code)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Signal: cannot reach signal-cli at %s: %s", self.http_url, e)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Signal: cannot reach signal-cli at %s: %s", self.http_url, e)
|
||||
return False
|
||||
|
||||
self._running = True
|
||||
self._last_sse_activity = time.time()
|
||||
self._sse_task = asyncio.create_task(self._sse_listener())
|
||||
self._health_monitor_task = asyncio.create_task(self._health_monitor())
|
||||
self._running = True
|
||||
self._last_sse_activity = time.time()
|
||||
self._sse_task = asyncio.create_task(self._sse_listener())
|
||||
self._health_monitor_task = asyncio.create_task(self._health_monitor())
|
||||
|
||||
logger.info("Signal: connected to %s", self.http_url)
|
||||
return True
|
||||
logger.info("Signal: connected to %s", self.http_url)
|
||||
return True
|
||||
finally:
|
||||
if not self._running:
|
||||
if self.client:
|
||||
await self.client.aclose()
|
||||
self.client = None
|
||||
if lock_acquired:
|
||||
self._release_platform_lock()
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Stop SSE listener and clean up."""
|
||||
@@ -400,6 +437,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
)
|
||||
sender_name = envelope_data.get("sourceName", "")
|
||||
sender_uuid = envelope_data.get("sourceUuid", "")
|
||||
self._remember_recipient_identifiers(sender, sender_uuid)
|
||||
|
||||
if not sender:
|
||||
logger.debug("Signal: ignoring envelope with no sender")
|
||||
@@ -518,6 +556,64 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
|
||||
await self.handle_message(event)
|
||||
|
||||
def _remember_recipient_identifiers(self, number: Optional[str], service_id: Optional[str]) -> None:
|
||||
"""Cache any number↔UUID mapping observed from Signal envelopes."""
|
||||
if not number or not service_id or not _is_signal_service_id(service_id):
|
||||
return
|
||||
self._recipient_uuid_by_number[number] = service_id
|
||||
self._recipient_number_by_uuid[service_id] = number
|
||||
|
||||
def _extract_contact_uuid(self, contact: Any, phone_number: str) -> Optional[str]:
|
||||
"""Best-effort extraction of a Signal service ID from listContacts output."""
|
||||
if not isinstance(contact, dict):
|
||||
return None
|
||||
|
||||
number = contact.get("number")
|
||||
recipient = contact.get("recipient")
|
||||
service_id = contact.get("uuid") or contact.get("serviceId")
|
||||
if not service_id:
|
||||
profile = contact.get("profile")
|
||||
if isinstance(profile, dict):
|
||||
service_id = profile.get("serviceId") or profile.get("uuid")
|
||||
|
||||
if service_id and _is_signal_service_id(service_id):
|
||||
matches_number = number == phone_number or recipient == phone_number
|
||||
if matches_number:
|
||||
return service_id
|
||||
return None
|
||||
|
||||
async def _resolve_recipient(self, chat_id: str) -> str:
|
||||
"""Return the preferred Signal recipient identifier for a direct chat."""
|
||||
if (
|
||||
not chat_id
|
||||
or chat_id.startswith("group:")
|
||||
or _is_signal_service_id(chat_id)
|
||||
or not _looks_like_e164_number(chat_id)
|
||||
):
|
||||
return chat_id
|
||||
|
||||
cached = self._recipient_uuid_by_number.get(chat_id)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with self._recipient_cache_lock:
|
||||
cached = self._recipient_uuid_by_number.get(chat_id)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
contacts = await self._rpc("listContacts", {
|
||||
"account": self.account,
|
||||
"allRecipients": True,
|
||||
})
|
||||
if isinstance(contacts, list):
|
||||
for contact in contacts:
|
||||
number = contact.get("number") if isinstance(contact, dict) else None
|
||||
service_id = self._extract_contact_uuid(contact, chat_id)
|
||||
if number and service_id:
|
||||
self._remember_recipient_identifiers(number, service_id)
|
||||
|
||||
return self._recipient_uuid_by_number.get(chat_id, chat_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Attachment Handling
|
||||
# ------------------------------------------------------------------
|
||||
@@ -633,7 +729,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if chat_id.startswith("group:"):
|
||||
params["groupId"] = chat_id[6:]
|
||||
else:
|
||||
params["recipient"] = [chat_id]
|
||||
params["recipient"] = [await self._resolve_recipient(chat_id)]
|
||||
|
||||
result = await self._rpc("send", params)
|
||||
|
||||
@@ -684,7 +780,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if chat_id.startswith("group:"):
|
||||
params["groupId"] = chat_id[6:]
|
||||
else:
|
||||
params["recipient"] = [chat_id]
|
||||
params["recipient"] = [await self._resolve_recipient(chat_id)]
|
||||
|
||||
fails = self._typing_failures.get(chat_id, 0)
|
||||
result = await self._rpc(
|
||||
@@ -745,7 +841,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if chat_id.startswith("group:"):
|
||||
params["groupId"] = chat_id[6:]
|
||||
else:
|
||||
params["recipient"] = [chat_id]
|
||||
params["recipient"] = [await self._resolve_recipient(chat_id)]
|
||||
|
||||
result = await self._rpc("send", params)
|
||||
if result is not None:
|
||||
@@ -784,7 +880,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if chat_id.startswith("group:"):
|
||||
params["groupId"] = chat_id[6:]
|
||||
else:
|
||||
params["recipient"] = [chat_id]
|
||||
params["recipient"] = [await self._resolve_recipient(chat_id)]
|
||||
|
||||
result = await self._rpc("send", params)
|
||||
if result is not None:
|
||||
|
||||
@@ -150,9 +150,11 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
logger.warning("[Slack] Failed to read %s: %s", tokens_file, e)
|
||||
|
||||
lock_acquired = False
|
||||
try:
|
||||
if not self._acquire_platform_lock('slack-app-token', app_token, 'Slack app token'):
|
||||
return False
|
||||
lock_acquired = True
|
||||
|
||||
# First token is the primary — used for AsyncApp / Socket Mode
|
||||
primary_token = bot_tokens[0]
|
||||
@@ -228,6 +230,9 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.error("[Slack] Connection failed: %s", e, exc_info=True)
|
||||
return False
|
||||
finally:
|
||||
if lock_acquired and not self._running:
|
||||
self._release_platform_lock()
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Slack."""
|
||||
@@ -316,6 +321,8 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
content: str,
|
||||
*,
|
||||
finalize: bool = False,
|
||||
) -> SendResult:
|
||||
"""Edit a previously sent Slack message."""
|
||||
if not self._app:
|
||||
|
||||
@@ -11,6 +11,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import html as _html
|
||||
import re
|
||||
from typing import Dict, List, Optional, Any
|
||||
@@ -534,8 +535,23 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
break
|
||||
|
||||
if changed:
|
||||
with open(config_path, "w") as f:
|
||||
_yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=str(config_path.parent),
|
||||
suffix=".tmp",
|
||||
prefix=".config_",
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
_yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, config_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
logger.info(
|
||||
"[%s] Persisted thread_id=%s for topic '%s' in config.yaml",
|
||||
self.name, thread_id, topic_name,
|
||||
@@ -1081,6 +1097,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
content: str,
|
||||
*,
|
||||
finalize: bool = False,
|
||||
) -> SendResult:
|
||||
"""Edit a previously sent Telegram message."""
|
||||
if not self._bot:
|
||||
@@ -2256,22 +2274,27 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower()
|
||||
bot_id = getattr(self._bot, "id", None)
|
||||
expected = f"@{bot_username}" if bot_username else None
|
||||
|
||||
def _iter_sources():
|
||||
yield getattr(message, "text", None) or "", getattr(message, "entities", None) or []
|
||||
yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or []
|
||||
|
||||
# Telegram parses mentions server-side and emits MessageEntity objects
|
||||
# (type=mention for @username, type=text_mention for @FirstName targeting
|
||||
# a user without a public username). Only those entities are authoritative —
|
||||
# raw substring matches like "foo@hermes_bot.example" are not mentions
|
||||
# (bug #12545). Entities also correctly handle @handles inside URLs, code
|
||||
# blocks, and quoted text, where a regex scan would over-match.
|
||||
for source_text, entities in _iter_sources():
|
||||
if bot_username and f"@{bot_username}" in source_text.lower():
|
||||
return True
|
||||
for entity in entities:
|
||||
entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
|
||||
if entity_type == "mention" and bot_username:
|
||||
if entity_type == "mention" and expected:
|
||||
offset = int(getattr(entity, "offset", -1))
|
||||
length = int(getattr(entity, "length", 0))
|
||||
if offset < 0 or length <= 0:
|
||||
continue
|
||||
if source_text[offset:offset + length].strip().lower() == f"@{bot_username}":
|
||||
if source_text[offset:offset + length].strip().lower() == expected:
|
||||
return True
|
||||
elif entity_type == "text_mention":
|
||||
user = getattr(entity, "user", None)
|
||||
|
||||
@@ -313,24 +313,14 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
{"error": "Payload too large"}, status=413
|
||||
)
|
||||
|
||||
# ── Rate limiting ────────────────────────────────────────
|
||||
now = time.time()
|
||||
window = self._rate_counts.setdefault(route_name, [])
|
||||
window[:] = [t for t in window if now - t < 60]
|
||||
if len(window) >= self._rate_limit:
|
||||
return web.json_response(
|
||||
{"error": "Rate limit exceeded"}, status=429
|
||||
)
|
||||
window.append(now)
|
||||
|
||||
# Read body
|
||||
# Read body (must be done before any validation)
|
||||
try:
|
||||
raw_body = await request.read()
|
||||
except Exception as e:
|
||||
logger.error("[webhook] Failed to read body: %s", e)
|
||||
return web.json_response({"error": "Bad request"}, status=400)
|
||||
|
||||
# Validate HMAC signature (skip for INSECURE_NO_AUTH testing mode)
|
||||
# Validate HMAC signature FIRST (skip for INSECURE_NO_AUTH testing mode)
|
||||
secret = route_config.get("secret", self._global_secret)
|
||||
if secret and secret != _INSECURE_NO_AUTH:
|
||||
if not self._validate_signature(request, raw_body, secret):
|
||||
@@ -341,6 +331,16 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
{"error": "Invalid signature"}, status=401
|
||||
)
|
||||
|
||||
# ── Rate limiting (after auth) ───────────────────────────
|
||||
now = time.time()
|
||||
window = self._rate_counts.setdefault(route_name, [])
|
||||
window[:] = [t for t in window if now - t < 60]
|
||||
if len(window) >= self._rate_limit:
|
||||
return web.json_response(
|
||||
{"error": "Rate limit exceeded"}, status=429
|
||||
)
|
||||
window.append(now)
|
||||
|
||||
# Parse payload
|
||||
try:
|
||||
payload = json.loads(raw_body)
|
||||
|
||||
@@ -289,33 +289,35 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
logger.info("[%s] Bridge found at %s", self.name, bridge_path)
|
||||
|
||||
# Acquire scoped lock to prevent duplicate sessions
|
||||
lock_acquired = False
|
||||
try:
|
||||
if not self._acquire_platform_lock('whatsapp-session', str(self._session_path), 'WhatsApp session'):
|
||||
return False
|
||||
lock_acquired = True
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Could not acquire session lock (non-fatal): %s", self.name, e)
|
||||
|
||||
# Auto-install npm dependencies if node_modules doesn't exist
|
||||
bridge_dir = bridge_path.parent
|
||||
if not (bridge_dir / "node_modules").exists():
|
||||
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
|
||||
try:
|
||||
install_result = subprocess.run(
|
||||
["npm", "install", "--silent"],
|
||||
cwd=str(bridge_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if install_result.returncode != 0:
|
||||
print(f"[{self.name}] npm install failed: {install_result.stderr}")
|
||||
return False
|
||||
print(f"[{self.name}] Dependencies installed")
|
||||
except Exception as e:
|
||||
print(f"[{self.name}] Failed to install dependencies: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Auto-install npm dependencies if node_modules doesn't exist
|
||||
bridge_dir = bridge_path.parent
|
||||
if not (bridge_dir / "node_modules").exists():
|
||||
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
|
||||
try:
|
||||
install_result = subprocess.run(
|
||||
["npm", "install", "--silent"],
|
||||
cwd=str(bridge_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if install_result.returncode != 0:
|
||||
print(f"[{self.name}] npm install failed: {install_result.stderr}")
|
||||
return False
|
||||
print(f"[{self.name}] Dependencies installed")
|
||||
except Exception as e:
|
||||
print(f"[{self.name}] Failed to install dependencies: {e}")
|
||||
return False
|
||||
|
||||
# Ensure session directory exists
|
||||
self._session_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -452,10 +454,13 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self._release_platform_lock()
|
||||
logger.error("[%s] Failed to start bridge: %s", self.name, e, exc_info=True)
|
||||
self._close_bridge_log()
|
||||
return False
|
||||
finally:
|
||||
if not self._running:
|
||||
if lock_acquired:
|
||||
self._release_platform_lock()
|
||||
self._close_bridge_log()
|
||||
|
||||
def _close_bridge_log(self) -> None:
|
||||
"""Close the bridge log file handle if open."""
|
||||
@@ -655,6 +660,8 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
content: str,
|
||||
*,
|
||||
finalize: bool = False,
|
||||
) -> SendResult:
|
||||
"""Edit a previously sent message via the WhatsApp bridge."""
|
||||
if not self._running or not self._http_session:
|
||||
|
||||
+136
-191
@@ -629,7 +629,6 @@ class GatewayRunner:
|
||||
self._restart_drain_timeout = self._load_restart_drain_timeout()
|
||||
self._provider_routing = self._load_provider_routing()
|
||||
self._fallback_model = self._load_fallback_model()
|
||||
self._smart_model_routing = self._load_smart_model_routing()
|
||||
|
||||
# Wire process registry into session store for reset protection
|
||||
from tools.process_registry import process_registry
|
||||
@@ -787,6 +786,10 @@ class GatewayRunner:
|
||||
|
||||
_VOICE_MODE_PATH = _hermes_home / "gateway_voice_mode.json"
|
||||
|
||||
def _voice_key(self, platform: Platform, chat_id: str) -> str:
|
||||
"""Return a platform-namespaced key for voice mode state."""
|
||||
return f"{platform.value}:{chat_id}"
|
||||
|
||||
def _load_voice_modes(self) -> Dict[str, str]:
|
||||
try:
|
||||
data = json.loads(self._VOICE_MODE_PATH.read_text())
|
||||
@@ -797,11 +800,21 @@ class GatewayRunner:
|
||||
return {}
|
||||
|
||||
valid_modes = {"off", "voice_only", "all"}
|
||||
return {
|
||||
str(chat_id): mode
|
||||
for chat_id, mode in data.items()
|
||||
if mode in valid_modes
|
||||
}
|
||||
result = {}
|
||||
for chat_id, mode in data.items():
|
||||
if mode not in valid_modes:
|
||||
continue
|
||||
key = str(chat_id)
|
||||
# Skip legacy unprefixed keys (warn and skip)
|
||||
if ":" not in key:
|
||||
logger.warning(
|
||||
"Skipping legacy unprefixed voice mode key %r during migration. "
|
||||
"Re-enable voice mode on that chat to rebuild the prefixed key.",
|
||||
key,
|
||||
)
|
||||
continue
|
||||
result[key] = mode
|
||||
return result
|
||||
|
||||
def _save_voice_modes(self) -> None:
|
||||
try:
|
||||
@@ -827,9 +840,14 @@ class GatewayRunner:
|
||||
disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None)
|
||||
if not isinstance(disabled_chats, set):
|
||||
return
|
||||
platform = getattr(adapter, "platform", None)
|
||||
if not isinstance(platform, Platform):
|
||||
return
|
||||
disabled_chats.clear()
|
||||
prefix = f"{platform.value}:"
|
||||
disabled_chats.update(
|
||||
chat_id for chat_id, mode in self._voice_mode.items() if mode == "off"
|
||||
key[len(prefix):] for key, mode in self._voice_mode.items()
|
||||
if mode == "off" and key.startswith(prefix)
|
||||
)
|
||||
|
||||
async def _safe_adapter_disconnect(self, adapter, platform) -> None:
|
||||
@@ -1082,11 +1100,16 @@ class GatewayRunner:
|
||||
return model, runtime_kwargs
|
||||
|
||||
def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
"""Build the effective model/runtime config for a single turn.
|
||||
|
||||
Always uses the session's primary model/provider. If `/fast` is
|
||||
enabled and the model supports Priority Processing / Anthropic fast
|
||||
mode, attach `request_overrides` so the API call is marked
|
||||
accordingly.
|
||||
"""
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
primary = {
|
||||
"model": model,
|
||||
runtime = {
|
||||
"api_key": runtime_kwargs.get("api_key"),
|
||||
"base_url": runtime_kwargs.get("base_url"),
|
||||
"provider": runtime_kwargs.get("provider"),
|
||||
@@ -1095,7 +1118,18 @@ class GatewayRunner:
|
||||
"args": list(runtime_kwargs.get("args") or []),
|
||||
"credential_pool": runtime_kwargs.get("credential_pool"),
|
||||
}
|
||||
route = resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary)
|
||||
route = {
|
||||
"model": model,
|
||||
"runtime": runtime,
|
||||
"signature": (
|
||||
model,
|
||||
runtime["provider"],
|
||||
runtime["base_url"],
|
||||
runtime["api_mode"],
|
||||
runtime["command"],
|
||||
tuple(runtime["args"]),
|
||||
),
|
||||
}
|
||||
|
||||
service_tier = getattr(self, "_service_tier", None)
|
||||
if not service_tier:
|
||||
@@ -1103,7 +1137,7 @@ class GatewayRunner:
|
||||
return route
|
||||
|
||||
try:
|
||||
overrides = resolve_fast_mode_overrides(route.get("model"))
|
||||
overrides = resolve_fast_mode_overrides(route["model"])
|
||||
except Exception:
|
||||
overrides = None
|
||||
route["request_overrides"] = overrides
|
||||
@@ -1461,20 +1495,6 @@ class GatewayRunner:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _load_smart_model_routing() -> dict:
|
||||
"""Load optional smart cheap-vs-strong model routing config."""
|
||||
try:
|
||||
import yaml as _y
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
cfg = _y.safe_load(_f) or {}
|
||||
return cfg.get("smart_model_routing", {}) or {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _snapshot_running_agents(self) -> Dict[str, Any]:
|
||||
return {
|
||||
session_key: agent
|
||||
@@ -2942,10 +2962,59 @@ class GatewayRunner:
|
||||
return bool(check_ids & allowed_ids)
|
||||
|
||||
def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str:
|
||||
"""Return how unauthorized DMs should be handled for a platform."""
|
||||
"""Return how unauthorized DMs should be handled for a platform.
|
||||
|
||||
Resolution order:
|
||||
1. Explicit per-platform ``unauthorized_dm_behavior`` in config — always wins.
|
||||
2. Explicit global ``unauthorized_dm_behavior`` in config — wins when no per-platform.
|
||||
3. When an allowlist (``PLATFORM_ALLOWED_USERS`` or ``GATEWAY_ALLOWED_USERS``) is
|
||||
configured, default to ``"ignore"`` — the allowlist signals that the owner has
|
||||
deliberately restricted access; spamming unknown contacts with pairing codes
|
||||
is both noisy and a potential info-leak. (#9337)
|
||||
4. No allowlist and no explicit config → ``"pair"`` (open-gateway default).
|
||||
"""
|
||||
config = getattr(self, "config", None)
|
||||
if config and hasattr(config, "get_unauthorized_dm_behavior"):
|
||||
return config.get_unauthorized_dm_behavior(platform)
|
||||
|
||||
# Check for an explicit per-platform override first.
|
||||
if config and hasattr(config, "get_unauthorized_dm_behavior") and platform:
|
||||
platform_cfg = config.platforms.get(platform) if hasattr(config, "platforms") else None
|
||||
if platform_cfg and "unauthorized_dm_behavior" in getattr(platform_cfg, "extra", {}):
|
||||
# Operator explicitly configured behavior for this platform — respect it.
|
||||
return config.get_unauthorized_dm_behavior(platform)
|
||||
|
||||
# Check for an explicit global config override.
|
||||
if config and hasattr(config, "unauthorized_dm_behavior"):
|
||||
if config.unauthorized_dm_behavior != "pair": # non-default → explicit override
|
||||
return config.unauthorized_dm_behavior
|
||||
|
||||
# No explicit override. Fall back to allowlist-aware default:
|
||||
# if any allowlist is configured for this platform, silently drop
|
||||
# unauthorized messages instead of sending pairing codes.
|
||||
if platform:
|
||||
platform_env_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
|
||||
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
|
||||
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
|
||||
Platform.SLACK: "SLACK_ALLOWED_USERS",
|
||||
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
|
||||
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
|
||||
Platform.SMS: "SMS_ALLOWED_USERS",
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
|
||||
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
|
||||
Platform.WECOM: "WECOM_ALLOWED_USERS",
|
||||
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
|
||||
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
|
||||
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
|
||||
Platform.QQBOT: "QQ_ALLOWED_USERS",
|
||||
}
|
||||
if os.getenv(platform_env_map.get(platform, ""), "").strip():
|
||||
return "ignore"
|
||||
|
||||
if os.getenv("GATEWAY_ALLOWED_USERS", "").strip():
|
||||
return "ignore"
|
||||
|
||||
return "pair"
|
||||
|
||||
async def _handle_message(self, event: MessageEvent) -> Optional[str]:
|
||||
@@ -3235,6 +3304,20 @@ class GatewayRunner:
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "background":
|
||||
return await self._handle_background_command(event)
|
||||
|
||||
# Session-level toggles that are safe to run mid-agent —
|
||||
# /yolo can unblock a pending approval prompt, /verbose cycles
|
||||
# the tool-progress display mode for the ongoing stream.
|
||||
# Both modify session state without needing agent interaction
|
||||
# and must not be queued (the safety net would discard them).
|
||||
# /fast and /reasoning are config-only and take effect next
|
||||
# message, so they fall through to the catch-all busy response
|
||||
# below — users should wait and set them between turns.
|
||||
if _cmd_def_inner and _cmd_def_inner.name in ("yolo", "verbose"):
|
||||
if _cmd_def_inner.name == "yolo":
|
||||
return await self._handle_yolo_command(event)
|
||||
if _cmd_def_inner.name == "verbose":
|
||||
return await self._handle_verbose_command(event)
|
||||
|
||||
# Gateway-handled info/control commands with dedicated
|
||||
# running-agent handlers.
|
||||
if _cmd_def_inner and _cmd_def_inner.name in _DEDICATED_HANDLERS:
|
||||
@@ -3434,9 +3517,6 @@ class GatewayRunner:
|
||||
if canonical == "insights":
|
||||
return await self._handle_insights_command(event)
|
||||
|
||||
if canonical == "workspace":
|
||||
return await self._handle_workspace_command(event)
|
||||
|
||||
if canonical == "reload-mcp":
|
||||
return await self._handle_reload_mcp_command(event)
|
||||
|
||||
@@ -5783,11 +5863,13 @@ class GatewayRunner:
|
||||
"""Handle /voice [on|off|tts|channel|leave|status] command."""
|
||||
args = event.get_command_args().strip().lower()
|
||||
chat_id = event.source.chat_id
|
||||
platform = event.source.platform
|
||||
voice_key = self._voice_key(platform, chat_id)
|
||||
|
||||
adapter = self.adapters.get(event.source.platform)
|
||||
adapter = self.adapters.get(platform)
|
||||
|
||||
if args in ("on", "enable"):
|
||||
self._voice_mode[chat_id] = "voice_only"
|
||||
self._voice_mode[voice_key] = "voice_only"
|
||||
self._save_voice_modes()
|
||||
if adapter:
|
||||
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
|
||||
@@ -5797,13 +5879,13 @@ class GatewayRunner:
|
||||
"Use /voice tts to get voice replies for all messages."
|
||||
)
|
||||
elif args in ("off", "disable"):
|
||||
self._voice_mode[chat_id] = "off"
|
||||
self._voice_mode[voice_key] = "off"
|
||||
self._save_voice_modes()
|
||||
if adapter:
|
||||
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
|
||||
return "Voice mode disabled. Text-only replies."
|
||||
elif args == "tts":
|
||||
self._voice_mode[chat_id] = "all"
|
||||
self._voice_mode[voice_key] = "all"
|
||||
self._save_voice_modes()
|
||||
if adapter:
|
||||
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
|
||||
@@ -5816,7 +5898,7 @@ class GatewayRunner:
|
||||
elif args == "leave":
|
||||
return await self._handle_voice_channel_leave(event)
|
||||
elif args == "status":
|
||||
mode = self._voice_mode.get(chat_id, "off")
|
||||
mode = self._voice_mode.get(voice_key, "off")
|
||||
labels = {
|
||||
"off": "Off (text only)",
|
||||
"voice_only": "On (voice reply to voice messages)",
|
||||
@@ -5840,15 +5922,15 @@ class GatewayRunner:
|
||||
return f"Voice mode: {labels.get(mode, mode)}"
|
||||
else:
|
||||
# Toggle: off → on, on/all → off
|
||||
current = self._voice_mode.get(chat_id, "off")
|
||||
current = self._voice_mode.get(voice_key, "off")
|
||||
if current == "off":
|
||||
self._voice_mode[chat_id] = "voice_only"
|
||||
self._voice_mode[voice_key] = "voice_only"
|
||||
self._save_voice_modes()
|
||||
if adapter:
|
||||
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
|
||||
return "Voice mode enabled."
|
||||
else:
|
||||
self._voice_mode[chat_id] = "off"
|
||||
self._voice_mode[voice_key] = "off"
|
||||
self._save_voice_modes()
|
||||
if adapter:
|
||||
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
|
||||
@@ -5894,7 +5976,7 @@ class GatewayRunner:
|
||||
adapter._voice_text_channels[guild_id] = int(event.source.chat_id)
|
||||
if hasattr(adapter, "_voice_sources"):
|
||||
adapter._voice_sources[guild_id] = event.source.to_dict()
|
||||
self._voice_mode[event.source.chat_id] = "all"
|
||||
self._voice_mode[self._voice_key(event.source.platform, event.source.chat_id)] = "all"
|
||||
self._save_voice_modes()
|
||||
self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=False)
|
||||
return (
|
||||
@@ -5921,7 +6003,7 @@ class GatewayRunner:
|
||||
except Exception as e:
|
||||
logger.warning("Error leaving voice channel: %s", e)
|
||||
# Always clean up state even if leave raised an exception
|
||||
self._voice_mode[event.source.chat_id] = "off"
|
||||
self._voice_mode[self._voice_key(event.source.platform, event.source.chat_id)] = "off"
|
||||
self._save_voice_modes()
|
||||
self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=True)
|
||||
if hasattr(adapter, "_voice_input_callback"):
|
||||
@@ -5933,7 +6015,7 @@ class GatewayRunner:
|
||||
|
||||
Cleans up runner-side voice_mode state that the adapter cannot reach.
|
||||
"""
|
||||
self._voice_mode[chat_id] = "off"
|
||||
self._voice_mode[self._voice_key(Platform.DISCORD, chat_id)] = "off"
|
||||
self._save_voice_modes()
|
||||
adapter = self.adapters.get(Platform.DISCORD)
|
||||
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
|
||||
@@ -6019,7 +6101,7 @@ class GatewayRunner:
|
||||
return False
|
||||
|
||||
chat_id = event.source.chat_id
|
||||
voice_mode = self._voice_mode.get(chat_id, "off")
|
||||
voice_mode = self._voice_mode.get(self._voice_key(event.source.platform, chat_id), "off")
|
||||
is_voice_input = (event.message_type == MessageType.VOICE)
|
||||
|
||||
should = (
|
||||
@@ -7266,153 +7348,6 @@ class GatewayRunner:
|
||||
logger.error("Insights command error: %s", e, exc_info=True)
|
||||
return f"Error generating insights: {e}"
|
||||
|
||||
async def _handle_workspace_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /workspace command -- status, search, index management.
|
||||
|
||||
Subcommands: status, search <query>, list, retrieve <path>,
|
||||
delete <path>, index, roots [list|add|remove]. Default is status.
|
||||
"""
|
||||
args = event.get_command_args().strip()
|
||||
parts = args.split() if args else []
|
||||
action = parts[0].lower() if parts else "status"
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _run():
|
||||
from pathlib import Path as _Path
|
||||
|
||||
from workspace import get_indexer
|
||||
from workspace.config import load_workspace_config
|
||||
|
||||
config = load_workspace_config()
|
||||
if not config.enabled:
|
||||
return "Workspace is disabled (workspace.enabled = false)."
|
||||
|
||||
indexer = get_indexer(config)
|
||||
|
||||
if action == "status":
|
||||
info = indexer.status()
|
||||
if not info:
|
||||
return "No status available."
|
||||
lines = []
|
||||
for k, v in info.items():
|
||||
if k == "db_size_bytes":
|
||||
lines.append(f" {k}: {v / (1024 * 1024):.1f} MB")
|
||||
else:
|
||||
lines.append(f" {k}: {v}")
|
||||
return "\n".join(lines)
|
||||
|
||||
if action == "search":
|
||||
query = " ".join(parts[1:]).strip()
|
||||
if not query:
|
||||
return "Usage: /workspace search <query>"
|
||||
results = indexer.search(query, limit=10)
|
||||
if not results:
|
||||
return "No results found."
|
||||
out = []
|
||||
for r in results:
|
||||
section = f" [{r.section}]" if r.section else ""
|
||||
snippet = r.content[:200].replace("\n", " ")
|
||||
if len(r.content) > 200:
|
||||
snippet += "..."
|
||||
out.append(
|
||||
f"{r.path}:{r.line_start}-{r.line_end} "
|
||||
f"(score: {r.score:.1f}){section}\n {snippet}"
|
||||
)
|
||||
return "\n\n".join(out)
|
||||
|
||||
if action == "list":
|
||||
files = indexer.list_files()
|
||||
if not files:
|
||||
return "No files indexed."
|
||||
lines = [f"{len(files)} indexed files:"]
|
||||
for f in files[:20]:
|
||||
size_kb = f.get("size_bytes", 0) / 1024
|
||||
chunks = f.get("chunks", 0)
|
||||
lines.append(f" {f['path']} ({size_kb:.0f} KB, {chunks} chunks)")
|
||||
if len(files) > 20:
|
||||
lines.append(f" ... and {len(files) - 20} more")
|
||||
return "\n".join(lines)
|
||||
|
||||
if action == "retrieve":
|
||||
if len(parts) < 2:
|
||||
return "Usage: /workspace retrieve <path>"
|
||||
path = str(_Path(parts[1]).expanduser().resolve())
|
||||
results = indexer.retrieve(path)
|
||||
if not results:
|
||||
return f"No indexed chunks for: {path}"
|
||||
lines = [f"{len(results)} chunks for {path}:"]
|
||||
for r in results[:10]:
|
||||
section = f" [{r.section}]" if r.section else ""
|
||||
snippet = r.content[:150].replace("\n", " ")
|
||||
if len(r.content) > 150:
|
||||
snippet += "..."
|
||||
lines.append(
|
||||
f" chunk {r.chunk_index}: lines {r.line_start}-{r.line_end}{section}\n {snippet}"
|
||||
)
|
||||
if len(results) > 10:
|
||||
lines.append(f" ... and {len(results) - 10} more chunks")
|
||||
return "\n".join(lines)
|
||||
|
||||
if action == "delete":
|
||||
if len(parts) < 2:
|
||||
return "Usage: /workspace delete <path>"
|
||||
path = str(_Path(parts[1]).expanduser().resolve())
|
||||
deleted = indexer.delete(path)
|
||||
return f"Deleted from index: {path}" if deleted else f"Not found in index: {path}"
|
||||
|
||||
if action == "index":
|
||||
summary = indexer.index()
|
||||
return (
|
||||
f"Indexed {summary.files_indexed} files "
|
||||
f"({summary.chunks_created} chunks), "
|
||||
f"skipped {summary.files_skipped}, "
|
||||
f"errored {summary.files_errored}, "
|
||||
f"pruned {summary.files_pruned} stale. "
|
||||
f"Took {summary.duration_seconds:.1f}s."
|
||||
)
|
||||
|
||||
if action == "roots":
|
||||
sub = parts[1].lower() if len(parts) > 1 else "list"
|
||||
if sub == "list":
|
||||
roots = config.knowledgebase.roots
|
||||
if not roots:
|
||||
return "No workspace roots configured."
|
||||
return "\n".join(
|
||||
f" {r.path}" + (" (recursive)" if r.recursive else "")
|
||||
for r in roots
|
||||
)
|
||||
if sub == "add":
|
||||
if len(parts) < 3:
|
||||
return "Usage: /workspace roots add <path> [--recursive]"
|
||||
from workspace.commands import _add_root
|
||||
|
||||
root_path = str(_Path(parts[2]).expanduser().resolve())
|
||||
recursive = "--recursive" in parts[3:]
|
||||
_add_root(root_path, recursive)
|
||||
return f"Added workspace root: {root_path} (recursive={recursive})"
|
||||
if sub == "remove":
|
||||
if len(parts) < 3:
|
||||
return "Usage: /workspace roots remove <path>"
|
||||
from workspace.commands import _remove_root
|
||||
|
||||
root_path = str(_Path(parts[2]).expanduser().resolve())
|
||||
_remove_root(root_path)
|
||||
return f"Removed workspace root: {root_path}"
|
||||
return "Usage: /workspace roots [list|add|remove]"
|
||||
|
||||
return (
|
||||
f"Unknown workspace subcommand: {action}\n"
|
||||
"Usage: /workspace [status|search <query>|list|retrieve <path>|"
|
||||
"delete <path>|index|roots ...]"
|
||||
)
|
||||
|
||||
try:
|
||||
return await loop.run_in_executor(None, _run)
|
||||
except Exception as e:
|
||||
logger.error("Workspace command error: %s", e, exc_info=True)
|
||||
return f"Error: {e}"
|
||||
|
||||
async def _handle_reload_mcp_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /reload-mcp command -- disconnect and reconnect all MCP servers."""
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -10449,6 +10384,16 @@ class GatewayRunner:
|
||||
pending = pending_event.text or _build_media_placeholder(pending_event)
|
||||
logger.debug("Processing queued message after agent completion: '%s...'", pending[:40])
|
||||
|
||||
# Leftover /steer: if a steer arrived after the last tool batch
|
||||
# (e.g. during the final API call), the agent couldn't inject it
|
||||
# and returned it in result["pending_steer"]. Deliver it as the
|
||||
# next user turn so it isn't silently dropped.
|
||||
if result and not pending and not pending_event:
|
||||
_leftover_steer = result.get("pending_steer")
|
||||
if _leftover_steer:
|
||||
pending = _leftover_steer
|
||||
logger.debug("Delivering leftover /steer as next turn: '%s...'", pending[:40])
|
||||
|
||||
# Safety net: if the pending text is a slash command (e.g. "/stop",
|
||||
# "/new"), discard it — commands should never be passed to the agent
|
||||
# as user input. The primary fix is in base.py (commands bypass the
|
||||
|
||||
+9
-3
@@ -926,12 +926,18 @@ class SessionStore:
|
||||
continue
|
||||
# Never prune sessions with an active background process
|
||||
# attached — the user may still be waiting on output.
|
||||
# The callback is keyed by session_key (see process_registry.
|
||||
# has_active_for_session); passing session_id here used to
|
||||
# never match, so active sessions got pruned anyway.
|
||||
if self._has_active_processes_fn is not None:
|
||||
try:
|
||||
if self._has_active_processes_fn(entry.session_id):
|
||||
if self._has_active_processes_fn(entry.session_key):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"has_active_processes_fn raised during prune for %s: %s",
|
||||
entry.session_key, exc,
|
||||
)
|
||||
if entry.updated_at < cutoff:
|
||||
removed_keys.append(key)
|
||||
for key in removed_keys:
|
||||
|
||||
+27
-5
@@ -20,6 +20,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import shlex
|
||||
import ssl
|
||||
import stat
|
||||
import base64
|
||||
import hashlib
|
||||
@@ -151,7 +152,7 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
id="gemini",
|
||||
name="Google AI Studio",
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
inference_base_url="https://generativelanguage.googleapis.com/v1beta",
|
||||
api_key_env_vars=("GOOGLE_API_KEY", "GEMINI_API_KEY"),
|
||||
base_url_env_var="GEMINI_BASE_URL",
|
||||
),
|
||||
@@ -353,6 +354,9 @@ def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) ->
|
||||
"""
|
||||
if env_override:
|
||||
return env_override
|
||||
# No key → nothing to infer from. Return default without inspecting.
|
||||
if not api_key:
|
||||
return default_url
|
||||
if api_key.startswith("sk-kimi-"):
|
||||
return KIMI_CODE_BASE_URL
|
||||
return default_url
|
||||
@@ -480,6 +484,14 @@ def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) ->
|
||||
if env_override:
|
||||
return env_override
|
||||
|
||||
# No API key set → don't probe (would fire N×M HTTPS requests with an
|
||||
# empty Bearer token, all returning 401). This path is hit during
|
||||
# auxiliary-client auto-detection when the user has no Z.AI credentials
|
||||
# at all — the caller discards the result immediately, so the probe is
|
||||
# pure latency for every AIAgent construction.
|
||||
if not api_key:
|
||||
return default_url
|
||||
|
||||
# Check provider-state cache for a previously-detected endpoint.
|
||||
auth_store = _load_auth_store()
|
||||
state = _load_provider_state(auth_store, "zai") or {}
|
||||
@@ -1652,7 +1664,7 @@ def _resolve_verify(
|
||||
insecure: Optional[bool] = None,
|
||||
ca_bundle: Optional[str] = None,
|
||||
auth_state: Optional[Dict[str, Any]] = None,
|
||||
) -> bool | str:
|
||||
) -> bool | ssl.SSLContext:
|
||||
tls_state = auth_state.get("tls") if isinstance(auth_state, dict) else {}
|
||||
tls_state = tls_state if isinstance(tls_state, dict) else {}
|
||||
|
||||
@@ -1672,13 +1684,12 @@ def _resolve_verify(
|
||||
if effective_ca:
|
||||
ca_path = str(effective_ca)
|
||||
if not os.path.isfile(ca_path):
|
||||
import logging
|
||||
logging.getLogger("hermes.auth").warning(
|
||||
logger.warning(
|
||||
"CA bundle path does not exist: %s — falling back to default certificates",
|
||||
ca_path,
|
||||
)
|
||||
return True
|
||||
return ca_path
|
||||
return ssl.create_default_context(cafile=ca_path)
|
||||
return True
|
||||
|
||||
|
||||
@@ -2721,6 +2732,17 @@ def _update_config_for_provider(
|
||||
# Clear stale base_url to prevent contamination when switching providers
|
||||
model_cfg.pop("base_url", None)
|
||||
|
||||
# Clear stale api_key/api_mode left over from a previous custom provider.
|
||||
# When the user switches from e.g. a MiniMax custom endpoint
|
||||
# (api_mode=anthropic_messages, api_key=mxp-...) to a built-in provider
|
||||
# (e.g. OpenRouter), the stale api_key/api_mode would override the new
|
||||
# provider's credentials and transport choice. Built-in providers that
|
||||
# need a specific api_mode (copilot, xai) set it at request-resolution
|
||||
# time via `_copilot_runtime_api_mode` / `_detect_api_mode_for_url`, so
|
||||
# removing the persisted value here is safe.
|
||||
model_cfg.pop("api_key", None)
|
||||
model_cfg.pop("api_mode", None)
|
||||
|
||||
# When switching to a non-OpenRouter provider, ensure model.default is
|
||||
# valid for the new provider. An OpenRouter-formatted name like
|
||||
# "anthropic/claude-opus-4.6" will fail on direct-API providers.
|
||||
|
||||
@@ -201,7 +201,7 @@ def run_backup(args) -> None:
|
||||
else:
|
||||
zf.write(abs_path, arcname=str(rel_path))
|
||||
total_bytes += abs_path.stat().st_size
|
||||
except (PermissionError, OSError) as exc:
|
||||
except (PermissionError, OSError, ValueError) as exc:
|
||||
errors.append(f" {rel_path}: {exc}")
|
||||
continue
|
||||
|
||||
|
||||
@@ -145,10 +145,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
|
||||
cli_only=True, args_hint="[connect|disconnect|status]",
|
||||
subcommands=("connect", "disconnect", "status")),
|
||||
CommandDef("workspace", "Workspace status, search, and index management",
|
||||
"Tools & Skills",
|
||||
args_hint="[status|index|list|search|retrieve|delete|roots]",
|
||||
subcommands=("status", "index", "list", "search", "retrieve", "delete", "roots")),
|
||||
CommandDef("plugins", "List installed plugins and their status",
|
||||
"Tools & Skills", cli_only=True),
|
||||
|
||||
|
||||
+20
-55
@@ -474,13 +474,6 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
},
|
||||
|
||||
"smart_model_routing": {
|
||||
"enabled": False,
|
||||
"max_simple_chars": 160,
|
||||
"max_simple_words": 28,
|
||||
"cheap_model": {},
|
||||
},
|
||||
|
||||
# Auxiliary model config — provider:model for each side task.
|
||||
# Format: provider is the provider name, model is the model slug.
|
||||
# "auto" for provider = auto-detect best available provider.
|
||||
@@ -494,6 +487,7 @@ DEFAULT_CONFIG = {
|
||||
"base_url": "", # direct OpenAI-compatible endpoint (takes precedence over provider)
|
||||
"api_key": "", # API key for base_url (falls back to OPENAI_API_KEY)
|
||||
"timeout": 120, # seconds — LLM API call timeout; vision payloads need generous timeout
|
||||
"extra_body": {}, # OpenAI-compatible provider-specific request fields
|
||||
"download_timeout": 30, # seconds — image HTTP download timeout; increase for slow connections
|
||||
},
|
||||
"web_extract": {
|
||||
@@ -502,6 +496,7 @@ DEFAULT_CONFIG = {
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models
|
||||
"extra_body": {},
|
||||
},
|
||||
"compression": {
|
||||
"provider": "auto",
|
||||
@@ -509,6 +504,7 @@ DEFAULT_CONFIG = {
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 120, # seconds — compression summarises large contexts; increase for local models
|
||||
"extra_body": {},
|
||||
},
|
||||
"session_search": {
|
||||
"provider": "auto",
|
||||
@@ -516,6 +512,8 @@ DEFAULT_CONFIG = {
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
"max_concurrency": 3, # Clamp parallel summaries to avoid request-burst 429s on small providers
|
||||
},
|
||||
"skills_hub": {
|
||||
"provider": "auto",
|
||||
@@ -523,6 +521,7 @@ DEFAULT_CONFIG = {
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
},
|
||||
"approval": {
|
||||
"provider": "auto",
|
||||
@@ -530,6 +529,7 @@ DEFAULT_CONFIG = {
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
},
|
||||
"mcp": {
|
||||
"provider": "auto",
|
||||
@@ -537,6 +537,7 @@ DEFAULT_CONFIG = {
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
},
|
||||
"flush_memories": {
|
||||
"provider": "auto",
|
||||
@@ -544,6 +545,7 @@ DEFAULT_CONFIG = {
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
},
|
||||
"title_generation": {
|
||||
"provider": "auto",
|
||||
@@ -551,6 +553,7 @@ DEFAULT_CONFIG = {
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -562,9 +565,14 @@ DEFAULT_CONFIG = {
|
||||
"bell_on_complete": False,
|
||||
"show_reasoning": False,
|
||||
"streaming": False,
|
||||
"final_response_markdown": "strip", # render | strip | raw
|
||||
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
"user_message_preview": { # CLI: how many submitted user-message lines to echo back in scrollback
|
||||
"first_lines": 2,
|
||||
"last_lines": 2,
|
||||
},
|
||||
"interim_assistant_messages": True, # Gateway: show natural mid-turn assistant status messages
|
||||
"tool_progress_command": False, # Enable /verbose command in messaging gateway
|
||||
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
|
||||
@@ -818,29 +826,8 @@ DEFAULT_CONFIG = {
|
||||
"force_ipv4": False,
|
||||
},
|
||||
|
||||
# Workspace — local document directory for curated files.
|
||||
"workspace": {
|
||||
"enabled": True,
|
||||
"path": "", # empty = HERMES_HOME/workspace
|
||||
},
|
||||
|
||||
# Knowledgebase — indexing and search configuration for workspace files.
|
||||
"knowledgebase": {
|
||||
"roots": [], # [{path: "/abs/path", recursive: false}]
|
||||
"chunking": {
|
||||
"strategy": "standard", # "standard" | "semantic" | "neural"
|
||||
"chunk_size": 512, # words per chunk
|
||||
},
|
||||
"indexing": {
|
||||
"max_file_mb": 10, # skip files over this size
|
||||
},
|
||||
"search": {
|
||||
"default_limit": 20, # default search result count
|
||||
},
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 19,
|
||||
"_config_version": 20,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -2899,19 +2886,6 @@ _FALLBACK_COMMENT = """
|
||||
# fallback_model:
|
||||
# provider: openrouter
|
||||
# model: anthropic/claude-sonnet-4
|
||||
#
|
||||
# ── Smart Model Routing ────────────────────────────────────────────────
|
||||
# Optional cheap-vs-strong routing for simple turns.
|
||||
# Keeps the primary model for complex work, but can route short/simple
|
||||
# messages to a cheaper model across providers.
|
||||
#
|
||||
# smart_model_routing:
|
||||
# enabled: true
|
||||
# max_simple_chars: 160
|
||||
# max_simple_words: 28
|
||||
# cheap_model:
|
||||
# provider: openrouter
|
||||
# model: google/gemini-2.5-flash
|
||||
"""
|
||||
|
||||
|
||||
@@ -2943,19 +2917,6 @@ _COMMENTED_SECTIONS = """
|
||||
# fallback_model:
|
||||
# provider: openrouter
|
||||
# model: anthropic/claude-sonnet-4
|
||||
#
|
||||
# ── Smart Model Routing ────────────────────────────────────────────────
|
||||
# Optional cheap-vs-strong routing for simple turns.
|
||||
# Keeps the primary model for complex work, but can route short/simple
|
||||
# messages to a cheaper model across providers.
|
||||
#
|
||||
# smart_model_routing:
|
||||
# enabled: true
|
||||
# max_simple_chars: 160
|
||||
# max_simple_words: 28
|
||||
# cheap_model:
|
||||
# provider: openrouter
|
||||
# model: google/gemini-2.5-flash
|
||||
"""
|
||||
|
||||
|
||||
@@ -3418,6 +3379,10 @@ def show_config():
|
||||
print(f" Personality: {display.get('personality', 'kawaii')}")
|
||||
print(f" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}")
|
||||
print(f" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}")
|
||||
ump = display.get('user_message_preview', {}) if isinstance(display.get('user_message_preview', {}), dict) else {}
|
||||
ump_first = ump.get('first_lines', 2)
|
||||
ump_last = ump.get('last_lines', 2)
|
||||
print(f" User preview: first {ump_first} line(s), last {ump_last} line(s)")
|
||||
|
||||
# Terminal
|
||||
print()
|
||||
|
||||
@@ -277,6 +277,86 @@ def run_doctor(args):
|
||||
config_path = HERMES_HOME / 'config.yaml'
|
||||
if config_path.exists():
|
||||
check_ok(f"{_DHH}/config.yaml exists")
|
||||
|
||||
# Validate model.provider and model.default values
|
||||
try:
|
||||
import yaml as _yaml
|
||||
cfg = _yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
model_section = cfg.get("model") or {}
|
||||
provider_raw = (model_section.get("provider") or "").strip()
|
||||
provider = provider_raw.lower()
|
||||
default_model = (model_section.get("default") or model_section.get("model") or "").strip()
|
||||
|
||||
known_providers: set = set()
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto"}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from hermes_cli.auth import resolve_provider as _resolve_provider
|
||||
except Exception:
|
||||
_resolve_provider = None
|
||||
|
||||
canonical_provider = provider
|
||||
if provider and _resolve_provider is not None and provider != "auto":
|
||||
try:
|
||||
canonical_provider = _resolve_provider(provider)
|
||||
except Exception:
|
||||
canonical_provider = None
|
||||
|
||||
if provider and provider != "auto":
|
||||
if canonical_provider is None or (known_providers and canonical_provider not in known_providers):
|
||||
known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)"
|
||||
check_fail(
|
||||
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>'"
|
||||
)
|
||||
|
||||
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them
|
||||
if default_model and "/" in default_model and canonical_provider and canonical_provider not in ("openrouter", "custom", "auto", "ai-gateway", "kilocode", "opencode-zen", "huggingface", "nous"):
|
||||
check_warn(
|
||||
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
|
||||
"(vendor-prefixed slugs belong to aggregators like openrouter)",
|
||||
)
|
||||
issues.append(
|
||||
f"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. "
|
||||
"Either set model.provider to 'openrouter', or drop the vendor prefix."
|
||||
)
|
||||
|
||||
# Check credentials for the configured provider.
|
||||
# Limit to API-key providers in PROVIDER_REGISTRY — other provider
|
||||
# types (OAuth, SDK, openrouter/anthropic/custom/auto) have their
|
||||
# own env-var checks elsewhere in doctor, and get_auth_status()
|
||||
# returns a bare {logged_in: False} for anything it doesn't
|
||||
# explicitly dispatch, which would produce false positives.
|
||||
if canonical_provider and canonical_provider not in ("auto", "custom", "openrouter"):
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status
|
||||
pconfig = PROVIDER_REGISTRY.get(canonical_provider)
|
||||
if pconfig and getattr(pconfig, "auth_type", "") == "api_key":
|
||||
status = get_auth_status(canonical_provider) or {}
|
||||
configured = bool(status.get("configured") or status.get("logged_in") or status.get("api_key"))
|
||||
if not configured:
|
||||
check_fail(
|
||||
f"model.provider '{canonical_provider}' is set but no API key is configured",
|
||||
"(check ~/.hermes/.env or run 'hermes setup')",
|
||||
)
|
||||
issues.append(
|
||||
f"No credentials found for provider '{canonical_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>'"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
check_warn("Could not validate model/provider config", f"({e})")
|
||||
else:
|
||||
fallback_config = PROJECT_ROOT / 'cli-config.yaml'
|
||||
if fallback_config.exists():
|
||||
@@ -778,6 +858,16 @@ def run_doctor(args):
|
||||
elif response.status_code == 401:
|
||||
print(f"\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} ")
|
||||
issues.append("Check OPENROUTER_API_KEY in .env")
|
||||
elif response.status_code == 402:
|
||||
print(f"\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}")
|
||||
issues.append(
|
||||
"OpenRouter account has insufficient credits. "
|
||||
"Fix: run 'hermes config set model.provider <provider>' to switch providers, "
|
||||
"or fund your OpenRouter account at https://openrouter.ai/settings/credits"
|
||||
)
|
||||
elif response.status_code == 429:
|
||||
print(f"\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} ")
|
||||
issues.append("OpenRouter rate limit hit — consider switching to a different provider or waiting")
|
||||
else:
|
||||
print(f"\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} ")
|
||||
except Exception as e:
|
||||
|
||||
@@ -160,7 +160,6 @@ def _config_overrides(config: dict) -> dict[str, str]:
|
||||
("display", "streaming"),
|
||||
("display", "skin"),
|
||||
("display", "show_reasoning"),
|
||||
("smart_model_routing", "enabled"),
|
||||
("privacy", "redact_pii"),
|
||||
("tts", "provider"),
|
||||
]
|
||||
|
||||
+19
-97
@@ -19,13 +19,6 @@ Usage:
|
||||
hermes cron status # Check if cron scheduler is running
|
||||
hermes doctor # Check configuration and dependencies
|
||||
hermes honcho setup # Configure Honcho AI memory integration
|
||||
hermes workspace roots list/add/remove # Manage workspace root directories
|
||||
hermes workspace index # Index workspace files
|
||||
hermes workspace search <query> # Search indexed content
|
||||
hermes workspace search <query> --path <prefix> # Search with path filter
|
||||
hermes workspace search <query> --glob <pattern> # Search with glob pattern
|
||||
hermes workspace search <query> --limit <n> # Limit number of results
|
||||
hermes workspace search <query> --human # Human-readable output format
|
||||
hermes honcho status # Show Honcho config and connection status
|
||||
hermes honcho sessions # List directory → session name mappings
|
||||
hermes honcho map <name> # Map current directory to a session name
|
||||
@@ -700,6 +693,10 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:
|
||||
- If it looks like a session ID (contains underscore + hex), try direct lookup first.
|
||||
- Otherwise, treat it as a title and use resolve_session_by_title (auto-latest).
|
||||
- Falls back to the other method if the first doesn't match.
|
||||
- If the resolved session is a compression root, follow the chain forward
|
||||
to the latest continuation. Users who remember the old root ID (e.g.
|
||||
from an exit summary printed before the bug fix, or from notes) get
|
||||
resumed at the live tip instead of a stale parent with no messages.
|
||||
"""
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
@@ -708,14 +705,23 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:
|
||||
|
||||
# Try as exact session ID first
|
||||
session = db.get_session(name_or_id)
|
||||
resolved_id: Optional[str] = None
|
||||
if session:
|
||||
db.close()
|
||||
return session["id"]
|
||||
resolved_id = session["id"]
|
||||
else:
|
||||
# Try as title (with auto-latest for lineage)
|
||||
resolved_id = db.resolve_session_by_title(name_or_id)
|
||||
|
||||
if resolved_id:
|
||||
# Project forward through compression chain so resumes land on
|
||||
# the live tip instead of a dead compressed parent.
|
||||
try:
|
||||
resolved_id = db.get_compression_tip(resolved_id) or resolved_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try as title (with auto-latest for lineage)
|
||||
session_id = db.resolve_session_by_title(name_or_id)
|
||||
db.close()
|
||||
return session_id
|
||||
return resolved_id
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -2358,7 +2364,7 @@ def _model_flow_google_gemini_cli(_config, current_model=""):
|
||||
return
|
||||
|
||||
models = list(_PROVIDER_MODELS.get("google-gemini-cli") or [])
|
||||
default = current_model or (models[0] if models else "gemini-2.5-flash")
|
||||
default = current_model or (models[0] if models else "gemini-3-flash-preview")
|
||||
selected = _prompt_model_selection(models, current_model=default)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
@@ -8352,90 +8358,6 @@ Examples:
|
||||
)
|
||||
logs_parser.set_defaults(func=cmd_logs)
|
||||
|
||||
# =========================================================================
|
||||
# workspace command
|
||||
# =========================================================================
|
||||
workspace_parser = subparsers.add_parser(
|
||||
"workspace",
|
||||
help="Workspace indexing and search",
|
||||
description="Manage workspace roots, index files, search, and inspect the FTS5 index",
|
||||
)
|
||||
workspace_flag_parent = argparse.ArgumentParser(add_help=False)
|
||||
workspace_flag_parent.add_argument(
|
||||
"--human",
|
||||
action="store_true",
|
||||
help="Human-readable Rich output instead of JSON",
|
||||
)
|
||||
workspace_subparsers = workspace_parser.add_subparsers(dest="workspace_action")
|
||||
|
||||
# workspace roots
|
||||
roots_parser = workspace_subparsers.add_parser(
|
||||
"roots",
|
||||
help="Manage workspace roots",
|
||||
parents=[workspace_flag_parent],
|
||||
)
|
||||
roots_sub = roots_parser.add_subparsers(dest="roots_action")
|
||||
roots_sub.add_parser("list", help="List configured workspace roots", parents=[workspace_flag_parent])
|
||||
roots_add = roots_sub.add_parser("add", help="Add a workspace root", parents=[workspace_flag_parent])
|
||||
roots_add.add_argument("path", help="Directory path to add as workspace root")
|
||||
roots_add.add_argument("--recursive", action="store_true", help="Recursively index subdirectories")
|
||||
roots_rm = roots_sub.add_parser("remove", help="Remove a workspace root", parents=[workspace_flag_parent])
|
||||
roots_rm.add_argument("path", help="Directory path to remove")
|
||||
|
||||
# workspace index
|
||||
workspace_subparsers.add_parser(
|
||||
"index",
|
||||
help="Index all workspace roots into FTS5",
|
||||
parents=[workspace_flag_parent],
|
||||
)
|
||||
|
||||
# workspace search
|
||||
ws_search = workspace_subparsers.add_parser(
|
||||
"search",
|
||||
help="Search indexed workspace files",
|
||||
parents=[workspace_flag_parent],
|
||||
)
|
||||
ws_search.add_argument("query", help="Search query")
|
||||
ws_search.add_argument("--limit", type=int, help="Max results")
|
||||
ws_search.add_argument("--path", help="Filter by absolute path prefix")
|
||||
ws_search.add_argument("--glob", help="Filter by filename glob pattern")
|
||||
|
||||
# workspace status
|
||||
workspace_subparsers.add_parser(
|
||||
"status",
|
||||
help="Show workspace index status",
|
||||
parents=[workspace_flag_parent],
|
||||
)
|
||||
|
||||
# workspace list
|
||||
workspace_subparsers.add_parser(
|
||||
"list",
|
||||
help="List all indexed files",
|
||||
parents=[workspace_flag_parent],
|
||||
)
|
||||
|
||||
# workspace retrieve
|
||||
ws_retrieve = workspace_subparsers.add_parser(
|
||||
"retrieve",
|
||||
help="Retrieve all indexed chunks for a file",
|
||||
parents=[workspace_flag_parent],
|
||||
)
|
||||
ws_retrieve.add_argument("path", help="Absolute path to the file")
|
||||
|
||||
# workspace delete
|
||||
ws_delete = workspace_subparsers.add_parser(
|
||||
"delete",
|
||||
help="Delete a file from the workspace index",
|
||||
parents=[workspace_flag_parent],
|
||||
)
|
||||
ws_delete.add_argument("path", help="Absolute path to the file to remove")
|
||||
|
||||
def cmd_workspace(args):
|
||||
from workspace.commands import workspace_command
|
||||
workspace_command(args)
|
||||
|
||||
workspace_parser.set_defaults(func=cmd_workspace)
|
||||
|
||||
# =========================================================================
|
||||
# Parse and execute
|
||||
# =========================================================================
|
||||
|
||||
@@ -1035,6 +1035,13 @@ def list_authenticated_providers(
|
||||
seen_slugs.add(_cp.slug.lower())
|
||||
|
||||
# --- 3. User-defined endpoints from config ---
|
||||
# Track (name, base_url) of what section 3 emits so section 4 can skip
|
||||
# any overlapping ``custom_providers:`` entries. Callers typically pass
|
||||
# both (gateway/CLI invoke ``get_compatible_custom_providers()`` which
|
||||
# merges ``providers:`` into the list) — without this, the same endpoint
|
||||
# produces two picker rows: one bare-slug ("openrouter") from section 3
|
||||
# and one "custom:openrouter" from section 4, both labelled identically.
|
||||
_section3_emitted_pairs: set = set()
|
||||
if user_providers and isinstance(user_providers, dict):
|
||||
for ep_name, ep_cfg in user_providers.items():
|
||||
if not isinstance(ep_cfg, dict):
|
||||
@@ -1088,6 +1095,12 @@ def list_authenticated_providers(
|
||||
"api_url": api_url,
|
||||
})
|
||||
seen_slugs.add(ep_name.lower())
|
||||
_pair = (
|
||||
str(display_name).strip().lower(),
|
||||
str(api_url).strip().rstrip("/").lower(),
|
||||
)
|
||||
if _pair[0] and _pair[1]:
|
||||
_section3_emitted_pairs.add(_pair)
|
||||
|
||||
# --- 4. Saved custom providers from config ---
|
||||
# Each ``custom_providers`` entry represents one model under a named
|
||||
@@ -1146,6 +1159,17 @@ def list_authenticated_providers(
|
||||
for slug, grp in groups.items():
|
||||
if slug.lower() in seen_slugs:
|
||||
continue
|
||||
# Skip if section 3 already emitted this endpoint under its
|
||||
# ``providers:`` dict key — matches on (display_name, base_url),
|
||||
# the tuple section 4 groups by. Prevents two picker rows
|
||||
# labelled identically when callers pass both ``user_providers``
|
||||
# and a compatibility-merged ``custom_providers`` list.
|
||||
_pair_key = (
|
||||
str(grp["name"]).strip().lower(),
|
||||
str(grp["api_url"]).strip().rstrip("/").lower(),
|
||||
)
|
||||
if _pair_key[0] and _pair_key[1] and _pair_key in _section3_emitted_pairs:
|
||||
continue
|
||||
results.append({
|
||||
"slug": slug,
|
||||
"name": grp["name"],
|
||||
|
||||
+50
-7
@@ -128,16 +128,14 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
],
|
||||
"gemini": [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.1-flash-lite-preview",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
],
|
||||
"google-gemini-cli": [
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
],
|
||||
"zai": [
|
||||
"glm-5.1",
|
||||
@@ -552,7 +550,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
ProviderEntry("copilot", "GitHub Copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"),
|
||||
ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
|
||||
ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers (20+ open models)"),
|
||||
ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"),
|
||||
ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Gemini models — native Gemini API)"),
|
||||
ProviderEntry("google-gemini-cli", "Google Gemini (OAuth)", "Google Gemini via OAuth + Code Assist (free tier supported; no API key needed)"),
|
||||
ProviderEntry("deepseek", "DeepSeek", "DeepSeek (DeepSeek-V3, R1, coder — direct API)"),
|
||||
ProviderEntry("xai", "xAI", "xAI (Grok models — direct API)"),
|
||||
@@ -2106,6 +2104,51 @@ def validate_requested_model(
|
||||
),
|
||||
}
|
||||
|
||||
# MiniMax providers don't expose a /models endpoint — validate against
|
||||
# the static catalog instead, similar to openai-codex.
|
||||
if normalized in ("minimax", "minimax-cn"):
|
||||
try:
|
||||
catalog_models = provider_model_ids(normalized)
|
||||
except Exception:
|
||||
catalog_models = []
|
||||
if catalog_models:
|
||||
# Case-insensitive lookup (catalog uses mixed case like MiniMax-M2.7)
|
||||
catalog_lower = {m.lower(): m for m in catalog_models}
|
||||
if requested_for_lookup.lower() in catalog_lower:
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": True,
|
||||
"message": None,
|
||||
}
|
||||
# Auto-correct close matches (case-insensitive)
|
||||
catalog_lower_list = list(catalog_lower.keys())
|
||||
auto = get_close_matches(requested_for_lookup.lower(), catalog_lower_list, n=1, cutoff=0.9)
|
||||
if auto:
|
||||
corrected = catalog_lower[auto[0]]
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": True,
|
||||
"corrected_model": corrected,
|
||||
"message": f"Auto-corrected `{requested}` → `{corrected}`",
|
||||
}
|
||||
suggestions = get_close_matches(requested_for_lookup.lower(), catalog_lower_list, n=3, cutoff=0.5)
|
||||
suggestion_text = ""
|
||||
if suggestions:
|
||||
suggestion_text = "\n Similar models: " + ", ".join(f"`{catalog_lower[s]}`" for s in suggestions)
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": False,
|
||||
"message": (
|
||||
f"Note: `{requested}` was not found in the MiniMax catalog."
|
||||
f"{suggestion_text}"
|
||||
"\n MiniMax does not expose a /models endpoint, so Hermes cannot verify the model name."
|
||||
"\n The model may still work if it exists on the server."
|
||||
),
|
||||
}
|
||||
|
||||
# Probe the live API to check if the model actually exists
|
||||
api_models = fetch_api_models(api_key, base_url)
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ logger = logging.getLogger(__name__)
|
||||
VALID_HOOKS: Set[str] = {
|
||||
"pre_tool_call",
|
||||
"post_tool_call",
|
||||
"transform_terminal_output",
|
||||
"transform_tool_result",
|
||||
"pre_llm_call",
|
||||
"post_llm_call",
|
||||
"pre_api_request",
|
||||
|
||||
@@ -322,12 +322,16 @@ def normalize_provider(name: str) -> str:
|
||||
|
||||
|
||||
def get_provider(name: str) -> Optional[ProviderDef]:
|
||||
"""Look up a provider by id or alias, merging all data sources.
|
||||
"""Look up a built-in provider by id or alias.
|
||||
|
||||
Resolution order:
|
||||
1. Hermes overlays (for providers not in models.dev: nous, openai-codex, etc.)
|
||||
2. models.dev catalog + Hermes overlay
|
||||
3. User-defined providers from config (TODO: Phase 4)
|
||||
|
||||
User-defined providers from config.yaml (``providers:`` / ``custom_providers:``)
|
||||
are resolved by :func:`resolve_provider_full`, which layers ``resolve_user_provider``
|
||||
and ``resolve_custom_provider`` on top of this function. Callers that need
|
||||
user-config support should use ``resolve_provider_full`` instead.
|
||||
|
||||
Returns a fully-resolved ProviderDef or None.
|
||||
"""
|
||||
|
||||
@@ -38,14 +38,21 @@ def _normalize_custom_provider_name(value: str) -> str:
|
||||
def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
|
||||
"""Auto-detect api_mode from the resolved base URL.
|
||||
|
||||
Direct api.openai.com endpoints need the Responses API for GPT-5.x
|
||||
tool calls with reasoning (chat/completions returns 400).
|
||||
- Direct api.openai.com endpoints need the Responses API for GPT-5.x
|
||||
tool calls with reasoning (chat/completions returns 400).
|
||||
- Third-party Anthropic-compatible gateways (MiniMax, Zhipu GLM,
|
||||
LiteLLM proxies, etc.) conventionally expose the native Anthropic
|
||||
protocol under a ``/anthropic`` suffix — treat those as
|
||||
``anthropic_messages`` transport instead of the default
|
||||
``chat_completions``.
|
||||
"""
|
||||
normalized = (base_url or "").strip().lower().rstrip("/")
|
||||
if "api.x.ai" in normalized:
|
||||
return "codex_responses"
|
||||
if "api.openai.com" in normalized and "openrouter" not in normalized:
|
||||
return "codex_responses"
|
||||
if normalized.endswith("/anthropic"):
|
||||
return "anthropic_messages"
|
||||
return None
|
||||
|
||||
|
||||
@@ -194,8 +201,12 @@ def _resolve_runtime_from_pool_entry(
|
||||
elif provider in ("opencode-zen", "opencode-go"):
|
||||
from hermes_cli.models import opencode_model_api_mode
|
||||
api_mode = opencode_model_api_mode(provider, model_cfg.get("default", ""))
|
||||
elif base_url.rstrip("/").endswith("/anthropic"):
|
||||
api_mode = "anthropic_messages"
|
||||
else:
|
||||
# Auto-detect Anthropic-compatible endpoints (/anthropic suffix,
|
||||
# api.openai.com → codex_responses, api.x.ai → codex_responses).
|
||||
detected = _detect_api_mode_for_url(base_url)
|
||||
if detected:
|
||||
api_mode = detected
|
||||
|
||||
# OpenCode base URLs end with /v1 for OpenAI-compatible models, but the
|
||||
# Anthropic SDK prepends its own /v1/messages to the base_url. Strip the
|
||||
@@ -642,8 +653,11 @@ def _resolve_explicit_runtime(
|
||||
configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
|
||||
if configured_mode:
|
||||
api_mode = configured_mode
|
||||
elif base_url.rstrip("/").endswith("/anthropic"):
|
||||
api_mode = "anthropic_messages"
|
||||
else:
|
||||
# Auto-detect Anthropic-compatible endpoints (/anthropic suffix).
|
||||
detected = _detect_api_mode_for_url(base_url)
|
||||
if detected:
|
||||
api_mode = detected
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
@@ -965,10 +979,13 @@ def resolve_runtime_provider(
|
||||
elif provider in ("opencode-zen", "opencode-go"):
|
||||
from hermes_cli.models import opencode_model_api_mode
|
||||
api_mode = opencode_model_api_mode(provider, model_cfg.get("default", ""))
|
||||
# Auto-detect Anthropic-compatible endpoints by URL convention
|
||||
# (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)
|
||||
elif base_url.rstrip("/").endswith("/anthropic"):
|
||||
api_mode = "anthropic_messages"
|
||||
else:
|
||||
# Auto-detect Anthropic-compatible endpoints by URL convention
|
||||
# (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)
|
||||
# plus api.openai.com → codex_responses and api.x.ai → codex_responses.
|
||||
detected = _detect_api_mode_for_url(base_url)
|
||||
if detected:
|
||||
api_mode = detected
|
||||
# Strip trailing /v1 for OpenCode Anthropic models (see comment above).
|
||||
if api_mode == "anthropic_messages" and provider in ("opencode-zen", "opencode-go"):
|
||||
base_url = re.sub(r"/v1/?$", "", base_url)
|
||||
|
||||
+2
-2
@@ -89,8 +89,8 @@ _DEFAULT_PROVIDER_MODELS = {
|
||||
"grok-code-fast-1",
|
||||
],
|
||||
"gemini": [
|
||||
"gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-3.1-flash-lite-preview",
|
||||
"gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite",
|
||||
"gemini-3.1-pro-preview", "gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview", "gemini-3.1-flash-lite-preview",
|
||||
],
|
||||
"zai": ["glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
|
||||
"kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||
|
||||
+47
-7
@@ -31,12 +31,52 @@ def get_provider_request_timeout(
|
||||
if not isinstance(provider_config, dict):
|
||||
return None
|
||||
|
||||
if model:
|
||||
models = provider_config.get("models", {})
|
||||
model_config = models.get(model, {}) if isinstance(models, dict) else {}
|
||||
if isinstance(model_config, dict):
|
||||
timeout = _coerce_timeout(model_config.get("timeout_seconds"))
|
||||
if timeout is not None:
|
||||
return timeout
|
||||
model_config = _get_model_config(provider_config, model)
|
||||
if model_config is not None:
|
||||
timeout = _coerce_timeout(model_config.get("timeout_seconds"))
|
||||
if timeout is not None:
|
||||
return timeout
|
||||
|
||||
return _coerce_timeout(provider_config.get("request_timeout_seconds"))
|
||||
|
||||
|
||||
def get_provider_stale_timeout(
|
||||
provider_id: str, model: str | None = None
|
||||
) -> float | None:
|
||||
"""Return a configured non-stream stale timeout in seconds, if any."""
|
||||
if not provider_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
config = load_config()
|
||||
providers = config.get("providers", {}) if isinstance(config, dict) else {}
|
||||
provider_config = (
|
||||
providers.get(provider_id, {}) if isinstance(providers, dict) else {}
|
||||
)
|
||||
if not isinstance(provider_config, dict):
|
||||
return None
|
||||
|
||||
model_config = _get_model_config(provider_config, model)
|
||||
if model_config is not None:
|
||||
timeout = _coerce_timeout(model_config.get("stale_timeout_seconds"))
|
||||
if timeout is not None:
|
||||
return timeout
|
||||
|
||||
return _coerce_timeout(provider_config.get("stale_timeout_seconds"))
|
||||
|
||||
|
||||
def _get_model_config(
|
||||
provider_config: dict[str, object], model: str | None
|
||||
) -> dict[str, object] | None:
|
||||
if not model:
|
||||
return None
|
||||
|
||||
models = provider_config.get("models", {})
|
||||
model_config = models.get(model, {}) if isinstance(models, dict) else {}
|
||||
if isinstance(model_config, dict):
|
||||
return model_config
|
||||
return None
|
||||
|
||||
+1
-3
@@ -245,7 +245,7 @@ TIPS = [
|
||||
"Three plugin types: general (tools/hooks), memory providers, and context engines.",
|
||||
"hermes plugins install owner/repo installs plugins directly from GitHub.",
|
||||
"8 external memory providers available: Honcho, OpenViking, Mem0, Hindsight, and more.",
|
||||
"Plugin hooks include pre_tool_call, post_tool_call, pre_llm_call, and post_llm_call.",
|
||||
"Plugin hooks include pre/post_tool_call, pre/post_llm_call, and transform_terminal_output for output canonicalization.",
|
||||
|
||||
# --- Miscellaneous ---
|
||||
"Prompt caching (Anthropic) reduces costs by reusing cached system prompt prefixes.",
|
||||
@@ -323,7 +323,6 @@ TIPS = [
|
||||
"GPT-5 and Codex use 'developer' role instead of 'system' in the message format.",
|
||||
"Per-task auxiliary overrides: auxiliary.vision.provider, auxiliary.compression.model, etc. in config.yaml.",
|
||||
"The auxiliary client treats 'main' as a provider alias — resolves to your actual primary provider + model.",
|
||||
"Smart routing can auto-route simple queries to a cheaper model — set smart_model_routing.enabled: true.",
|
||||
"hermes claw migrate --dry-run previews OpenClaw migration without writing anything.",
|
||||
"File paths pasted with quotes or escaped spaces are handled automatically — no manual cleanup needed.",
|
||||
"Slash commands never trigger the large-paste collapse — /command with big arguments works correctly.",
|
||||
@@ -346,4 +345,3 @@ def get_random_tip(exclude_recent: int = 0) -> str:
|
||||
return random.choice(TIPS)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -232,8 +232,8 @@ _CATEGORY_MERGE: Dict[str, str] = {
|
||||
"checkpoints": "agent",
|
||||
"approvals": "security",
|
||||
"human_delay": "display",
|
||||
"smart_model_routing": "agent",
|
||||
"dashboard": "display",
|
||||
"code_execution": "agent",
|
||||
}
|
||||
|
||||
# Display order for tabs — unlisted categories sort alphabetically after these.
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
"""Slash command handler for /workspace in the interactive CLI.
|
||||
|
||||
Parses /workspace [subcommand] [args] and formats output with Rich.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
|
||||
def handle_workspace_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
console = console or Console()
|
||||
parts = cmd.strip().split()
|
||||
if parts and parts[0].lower() in ("/workspace", "workspace"):
|
||||
parts = parts[1:]
|
||||
|
||||
if not parts:
|
||||
_print_status(console)
|
||||
return
|
||||
|
||||
action = parts[0].lower()
|
||||
|
||||
if action == "status":
|
||||
_print_status(console)
|
||||
elif action == "index":
|
||||
_print_index(console)
|
||||
elif action == "list":
|
||||
_print_list(console)
|
||||
elif action == "search":
|
||||
query = " ".join(parts[1:]).strip()
|
||||
if not query:
|
||||
console.print("Usage: /workspace search <query>")
|
||||
return
|
||||
_print_search(console, query)
|
||||
elif action == "retrieve":
|
||||
path = parts[1] if len(parts) > 1 else ""
|
||||
if not path:
|
||||
console.print("Usage: /workspace retrieve <path>")
|
||||
return
|
||||
_print_retrieve(console, path)
|
||||
elif action == "delete":
|
||||
path = parts[1] if len(parts) > 1 else ""
|
||||
if not path:
|
||||
console.print("Usage: /workspace delete <path>")
|
||||
return
|
||||
_print_delete(console, path)
|
||||
elif action == "roots":
|
||||
_print_roots(console, parts[1:])
|
||||
else:
|
||||
console.print(
|
||||
"Usage: /workspace [status|index|list|search <query>"
|
||||
"|retrieve <path>|delete <path>|roots ...]"
|
||||
)
|
||||
|
||||
|
||||
def _get_indexer_and_config():
|
||||
from workspace import get_indexer
|
||||
from workspace.config import load_workspace_config
|
||||
|
||||
config = load_workspace_config()
|
||||
if not config.enabled:
|
||||
return None, config
|
||||
return get_indexer(config), config
|
||||
|
||||
|
||||
def _print_status(console: Console) -> None:
|
||||
indexer, config = _get_indexer_and_config()
|
||||
if indexer is None:
|
||||
console.print("[bold red]Workspace is disabled[/]")
|
||||
return
|
||||
info = indexer.status()
|
||||
if not info:
|
||||
console.print("No status available.")
|
||||
return
|
||||
for k, v in info.items():
|
||||
if k == "db_size_bytes":
|
||||
console.print(f" {k}: {v / (1024 * 1024):.1f} MB")
|
||||
else:
|
||||
console.print(f" {k}: {v}")
|
||||
|
||||
|
||||
def _print_search(console: Console, query: str) -> None:
|
||||
indexer, _ = _get_indexer_and_config()
|
||||
if indexer is None:
|
||||
console.print("[bold red]Workspace is disabled[/]")
|
||||
return
|
||||
results = indexer.search(query, limit=20)
|
||||
if not results:
|
||||
console.print("No results found.")
|
||||
return
|
||||
for r in results:
|
||||
section = f" {r.section}" if r.section else ""
|
||||
console.print(
|
||||
f"\n{r.path}:{r.line_start}-{r.line_end} "
|
||||
f"(score: {r.score:.1f}){section}"
|
||||
)
|
||||
snippet = r.content[:200].replace("\n", " ")
|
||||
if len(r.content) > 200:
|
||||
snippet += "..."
|
||||
console.print(f" {snippet}")
|
||||
|
||||
|
||||
def _print_list(console: Console) -> None:
|
||||
indexer, _ = _get_indexer_and_config()
|
||||
if indexer is None:
|
||||
console.print("[bold red]Workspace is disabled[/]")
|
||||
return
|
||||
files = indexer.list_files()
|
||||
if not files:
|
||||
console.print("No files indexed.")
|
||||
return
|
||||
console.print(f"{len(files)} indexed files:\n")
|
||||
for f in files:
|
||||
size_kb = f.get("size_bytes", 0) / 1024
|
||||
chunks = f.get("chunks", 0)
|
||||
console.print(f" {f['path']} ({size_kb:.0f} KB, {chunks} chunks)")
|
||||
|
||||
|
||||
def _print_retrieve(console: Console, raw_path: str) -> None:
|
||||
indexer, _ = _get_indexer_and_config()
|
||||
if indexer is None:
|
||||
console.print("[bold red]Workspace is disabled[/]")
|
||||
return
|
||||
path = str(Path(raw_path).expanduser().resolve())
|
||||
results = indexer.retrieve(path)
|
||||
if not results:
|
||||
console.print(f"No indexed chunks for: {path}")
|
||||
return
|
||||
console.print(f"{len(results)} chunks for {path}:\n")
|
||||
for r in results:
|
||||
section = f" [{r.section}]" if r.section else ""
|
||||
console.print(f" chunk {r.chunk_index}: lines {r.line_start}-{r.line_end}{section}")
|
||||
snippet = r.content[:200].replace("\n", " ")
|
||||
if len(r.content) > 200:
|
||||
snippet += "..."
|
||||
console.print(f" {snippet}\n")
|
||||
|
||||
|
||||
def _print_delete(console: Console, raw_path: str) -> None:
|
||||
indexer, _ = _get_indexer_and_config()
|
||||
if indexer is None:
|
||||
console.print("[bold red]Workspace is disabled[/]")
|
||||
return
|
||||
path = str(Path(raw_path).expanduser().resolve())
|
||||
deleted = indexer.delete(path)
|
||||
if deleted:
|
||||
console.print(f"Deleted from index: {path}")
|
||||
else:
|
||||
console.print(f"Not found in index: {path}")
|
||||
|
||||
|
||||
def _print_index(console: Console) -> None:
|
||||
indexer, _ = _get_indexer_and_config()
|
||||
if indexer is None:
|
||||
console.print("[bold red]Workspace is disabled[/]")
|
||||
return
|
||||
|
||||
def _progress(current: int, total: int, path: str) -> None:
|
||||
name = Path(path).name
|
||||
console.print(f" [{current}/{total}] {name}", highlight=False)
|
||||
|
||||
summary = indexer.index(progress=_progress)
|
||||
console.print(
|
||||
f"\nIndexed {summary.files_indexed} files "
|
||||
f"({summary.chunks_created} chunks), "
|
||||
f"skipped {summary.files_skipped}, "
|
||||
f"errored {summary.files_errored}, "
|
||||
f"pruned {summary.files_pruned} stale. "
|
||||
f"Took {summary.duration_seconds:.1f}s."
|
||||
)
|
||||
if summary.errors:
|
||||
console.print("\n[bold red]Errors:[/]")
|
||||
for err in summary.errors:
|
||||
console.print(f" [{err.stage}] {err.path}: {err.message}")
|
||||
|
||||
|
||||
def _print_roots(console: Console, parts: list[str]) -> None:
|
||||
from workspace.config import load_workspace_config
|
||||
|
||||
if not parts or parts[0].lower() == "list":
|
||||
config = load_workspace_config()
|
||||
roots = config.knowledgebase.roots
|
||||
if not roots:
|
||||
console.print("No workspace roots configured.")
|
||||
return
|
||||
for r in roots:
|
||||
flag = " (recursive)" if r.recursive else ""
|
||||
console.print(f" {r.path}{flag}")
|
||||
return
|
||||
|
||||
action = parts[0].lower()
|
||||
if action == "add":
|
||||
if len(parts) < 2:
|
||||
console.print("Usage: /workspace roots add <path> [--recursive]")
|
||||
return
|
||||
path = str(Path(parts[1]).expanduser().resolve())
|
||||
recursive = "--recursive" in parts[2:]
|
||||
from workspace.commands import _add_root
|
||||
|
||||
_add_root(path, recursive)
|
||||
console.print(f"Added workspace root: {path} (recursive={recursive})")
|
||||
elif action == "remove":
|
||||
if len(parts) < 2:
|
||||
console.print("Usage: /workspace roots remove <path>")
|
||||
return
|
||||
path = str(Path(parts[1]).expanduser().resolve())
|
||||
from workspace.commands import _remove_root
|
||||
|
||||
_remove_root(path)
|
||||
console.print(f"Removed workspace root: {path}")
|
||||
else:
|
||||
console.print("Usage: /workspace roots [list|add|remove]")
|
||||
+125
-2
@@ -383,10 +383,19 @@ class SessionDB:
|
||||
return session_id
|
||||
|
||||
def end_session(self, session_id: str, end_reason: str) -> None:
|
||||
"""Mark a session as ended."""
|
||||
"""Mark a session as ended.
|
||||
|
||||
No-ops when the session is already ended. The first end_reason wins:
|
||||
compression-split sessions must keep their ``end_reason = 'compression'``
|
||||
record even if a later stale ``end_session()`` call (e.g. from a
|
||||
desynced CLI session_id after ``/resume`` or ``/branch``) targets them
|
||||
with a different reason. Use ``reopen_session()`` first if you
|
||||
intentionally need to re-end a closed session with a new reason.
|
||||
"""
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ?",
|
||||
"UPDATE sessions SET ended_at = ?, end_reason = ? "
|
||||
"WHERE id = ? AND ended_at IS NULL",
|
||||
(time.time(), end_reason, session_id),
|
||||
)
|
||||
self._execute_write(_do)
|
||||
@@ -714,6 +723,42 @@ class SessionDB:
|
||||
|
||||
return f"{base} #{max_num + 1}"
|
||||
|
||||
def get_compression_tip(self, session_id: str) -> Optional[str]:
|
||||
"""Walk the compression-continuation chain forward and return the tip.
|
||||
|
||||
A compression continuation is a child session where:
|
||||
1. The parent's ``end_reason = 'compression'``
|
||||
2. The child was created AFTER the parent was ended (started_at >= ended_at)
|
||||
|
||||
The second condition distinguishes compression continuations from
|
||||
delegate subagents or branch children, which can also have a
|
||||
``parent_session_id`` but were created while the parent was still live.
|
||||
|
||||
Returns the session_id of the latest continuation in the chain, or the
|
||||
input ``session_id`` if it isn't part of a compression chain (or if the
|
||||
input itself doesn't exist).
|
||||
"""
|
||||
current = session_id
|
||||
# Bound the walk defensively — compression chains this deep are
|
||||
# pathological and shouldn't happen in practice. 100 = plenty.
|
||||
for _ in range(100):
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT id FROM sessions "
|
||||
"WHERE parent_session_id = ? "
|
||||
" AND started_at >= ("
|
||||
" SELECT ended_at FROM sessions "
|
||||
" WHERE id = ? AND end_reason = 'compression'"
|
||||
" ) "
|
||||
"ORDER BY started_at DESC LIMIT 1",
|
||||
(current, current),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
return current
|
||||
current = row["id"]
|
||||
return current
|
||||
|
||||
def list_sessions_rich(
|
||||
self,
|
||||
source: str = None,
|
||||
@@ -721,6 +766,7 @@ class SessionDB:
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
include_children: bool = False,
|
||||
project_compression_tips: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List sessions with preview (first user message) and last active timestamp.
|
||||
|
||||
@@ -732,6 +778,14 @@ class SessionDB:
|
||||
|
||||
By default, child sessions (subagent runs, compression continuations)
|
||||
are excluded. Pass ``include_children=True`` to include them.
|
||||
|
||||
With ``project_compression_tips=True`` (default), sessions that are
|
||||
roots of compression chains are projected forward to their latest
|
||||
continuation — one logical conversation = one list entry, showing the
|
||||
live continuation's id/message_count/title/last_active. This prevents
|
||||
compressed continuations from being invisible to users while keeping
|
||||
delegate subagents and branches hidden. Pass ``False`` to return the
|
||||
raw root rows (useful for admin/debug UIs).
|
||||
"""
|
||||
where_clauses = []
|
||||
params = []
|
||||
@@ -782,8 +836,77 @@ class SessionDB:
|
||||
s["preview"] = ""
|
||||
sessions.append(s)
|
||||
|
||||
# Project compression roots forward to their tips. Each row whose
|
||||
# end_reason is 'compression' has a continuation child; replace the
|
||||
# surfaced fields (id, message_count, title, last_active, ended_at,
|
||||
# end_reason, preview) with the tip's values so the list entry acts
|
||||
# as the live conversation. Keep the root's started_at to preserve
|
||||
# chronological ordering by original conversation start.
|
||||
if project_compression_tips and not include_children:
|
||||
projected = []
|
||||
for s in sessions:
|
||||
if s.get("end_reason") != "compression":
|
||||
projected.append(s)
|
||||
continue
|
||||
tip_id = self.get_compression_tip(s["id"])
|
||||
if tip_id == s["id"]:
|
||||
projected.append(s)
|
||||
continue
|
||||
tip_row = self._get_session_rich_row(tip_id)
|
||||
if not tip_row:
|
||||
projected.append(s)
|
||||
continue
|
||||
# Preserve the root's started_at for stable sort order, but
|
||||
# surface the tip's identity and activity data.
|
||||
merged = dict(s)
|
||||
for key in (
|
||||
"id", "ended_at", "end_reason", "message_count",
|
||||
"tool_call_count", "title", "last_active", "preview",
|
||||
"model", "system_prompt",
|
||||
):
|
||||
if key in tip_row:
|
||||
merged[key] = tip_row[key]
|
||||
merged["_lineage_root_id"] = s["id"]
|
||||
projected.append(merged)
|
||||
sessions = projected
|
||||
|
||||
return sessions
|
||||
|
||||
def _get_session_rich_row(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch a single session with the same enriched columns as
|
||||
``list_sessions_rich`` (preview + last_active). Returns None if the
|
||||
session doesn't exist.
|
||||
"""
|
||||
query = """
|
||||
SELECT s.*,
|
||||
COALESCE(
|
||||
(SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
|
||||
FROM messages m
|
||||
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
|
||||
ORDER BY m.timestamp, m.id LIMIT 1),
|
||||
''
|
||||
) AS _preview_raw,
|
||||
COALESCE(
|
||||
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
|
||||
s.started_at
|
||||
) AS last_active
|
||||
FROM sessions s
|
||||
WHERE s.id = ?
|
||||
"""
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(query, (session_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
s = dict(row)
|
||||
raw = s.pop("_preview_raw", "").strip()
|
||||
if raw:
|
||||
text = raw[:60]
|
||||
s["preview"] = text + ("..." if len(raw) > 60 else "")
|
||||
else:
|
||||
s["preview"] = ""
|
||||
return s
|
||||
|
||||
# =========================================================================
|
||||
# Message storage
|
||||
# =========================================================================
|
||||
|
||||
+9
-3
@@ -43,13 +43,16 @@ from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _effective_temperature_for_model(model: str) -> Optional[float]:
|
||||
def _effective_temperature_for_model(
|
||||
model: str,
|
||||
base_url: Optional[str] = None,
|
||||
) -> Optional[float]:
|
||||
"""Return a fixed temperature for models with strict sampling contracts."""
|
||||
try:
|
||||
from agent.auxiliary_client import _fixed_temperature_for_model
|
||||
except Exception:
|
||||
return None
|
||||
return _fixed_temperature_for_model(model)
|
||||
return _fixed_temperature_for_model(model, base_url)
|
||||
|
||||
|
||||
|
||||
@@ -457,7 +460,10 @@ Complete the user's task step by step."""
|
||||
"tools": self.tools,
|
||||
"timeout": 300.0,
|
||||
}
|
||||
fixed_temperature = _effective_temperature_for_model(self.model)
|
||||
fixed_temperature = _effective_temperature_for_model(
|
||||
self.model,
|
||||
str(getattr(self.client, "base_url", "") or ""),
|
||||
)
|
||||
if fixed_temperature is not None:
|
||||
api_kwargs["temperature"] = fixed_temperature
|
||||
|
||||
|
||||
@@ -550,6 +550,30 @@ def handle_function_call(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Generic tool-result canonicalization seam: plugins receive the
|
||||
# final result string (JSON, usually) and may replace it by
|
||||
# returning a string from transform_tool_result. Runs after
|
||||
# post_tool_call (which stays observational) and before the result
|
||||
# is appended back into conversation context. Fail-open; the first
|
||||
# valid string return wins; non-string returns are ignored.
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook
|
||||
hook_results = invoke_hook(
|
||||
"transform_tool_result",
|
||||
tool_name=function_name,
|
||||
args=function_args,
|
||||
result=result,
|
||||
task_id=task_id or "",
|
||||
session_id=session_id or "",
|
||||
tool_call_id=tool_call_id or "",
|
||||
)
|
||||
for hook_result in hook_results:
|
||||
if isinstance(hook_result, str):
|
||||
result = hook_result
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Add a first-class `gemini` provider that authenticates via Google OAuth, using the standard Gemini API (not Cloud Code Assist). Users who have a Google AI subscription or Gemini API access can authenticate through the browser without needing to manually copy API keys.
|
||||
|
||||
## Architecture Decision
|
||||
- **Path A (chosen):** Standard Gemini API at `generativelanguage.googleapis.com/v1beta/openai/`
|
||||
- **Path A (chosen):** Standard Gemini API at `generativelanguage.googleapis.com/v1beta`
|
||||
- **NOT Path B:** Cloud Code Assist (`cloudcode-pa.googleapis.com`) — rate-limited free tier, internal API, account ban risk
|
||||
- Standard `chat_completions` api_mode via OpenAI SDK — no new api_mode needed
|
||||
- Our own OAuth credentials — NOT sharing tokens with Gemini CLI
|
||||
@@ -32,9 +32,9 @@ Add a first-class `gemini` provider that authenticates via Google OAuth, using t
|
||||
- File locking for concurrent access (multiple agent sessions)
|
||||
|
||||
## API Integration
|
||||
- Base URL: `https://generativelanguage.googleapis.com/v1beta/openai/`
|
||||
- Auth: `Authorization: Bearer <access_token>` (passed as `api_key` to OpenAI SDK)
|
||||
- api_mode: `chat_completions` (standard)
|
||||
- Base URL: `https://generativelanguage.googleapis.com/v1beta`
|
||||
- Auth: native Gemini API authentication handled by the provider adapter
|
||||
- api_mode: `chat_completions` (standard facade over native transport)
|
||||
- Models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.0-flash, etc.
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
"""Workspace indexer plugin discovery.
|
||||
|
||||
Scans ``plugins/workspace/<name>/`` directories for indexer plugins.
|
||||
Each subdirectory must contain ``__init__.py`` with a class implementing
|
||||
the BaseIndexer ABC.
|
||||
|
||||
Usage:
|
||||
from plugins.workspace import discover_workspace_indexers, load_workspace_indexer
|
||||
|
||||
available = discover_workspace_indexers()
|
||||
indexer_cls = load_workspace_indexer("witchcraft")
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WORKSPACE_PLUGINS_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def discover_workspace_indexers() -> list[tuple[str, str, bool]]:
|
||||
"""Scan plugins/workspace/ for available indexer plugins.
|
||||
|
||||
Returns list of (name, description, is_available) tuples.
|
||||
Does NOT import the indexers — just reads plugin.yaml for metadata
|
||||
and does a lightweight availability check.
|
||||
"""
|
||||
results: list[tuple[str, str, bool]] = []
|
||||
if not _WORKSPACE_PLUGINS_DIR.is_dir():
|
||||
return results
|
||||
|
||||
for child in sorted(_WORKSPACE_PLUGINS_DIR.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith(("_", ".")):
|
||||
continue
|
||||
init_file = child / "__init__.py"
|
||||
if not init_file.exists():
|
||||
continue
|
||||
|
||||
# Read description from plugin.yaml if available
|
||||
desc = ""
|
||||
yaml_file = child / "plugin.yaml"
|
||||
if yaml_file.exists():
|
||||
try:
|
||||
import yaml
|
||||
|
||||
with open(yaml_file) as f:
|
||||
meta = yaml.safe_load(f) or {}
|
||||
desc = meta.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Quick availability check — try loading
|
||||
available = True
|
||||
try:
|
||||
cls = _load_indexer_from_dir(child)
|
||||
available = cls is not None
|
||||
except Exception:
|
||||
available = False
|
||||
|
||||
results.append((child.name, desc, available))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def load_workspace_indexer(name: str) -> Optional[type]:
|
||||
"""Load and return a workspace indexer class by name.
|
||||
|
||||
Returns the class (not an instance) so the caller can instantiate
|
||||
with ``cls(config)``. Returns None if not found or on failure.
|
||||
"""
|
||||
engine_dir = _WORKSPACE_PLUGINS_DIR / name
|
||||
if not engine_dir.is_dir():
|
||||
logger.debug(
|
||||
"Workspace indexer '%s' not found in %s", name, _WORKSPACE_PLUGINS_DIR
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
cls = _load_indexer_from_dir(engine_dir)
|
||||
if cls:
|
||||
return cls
|
||||
logger.warning("Workspace indexer '%s' loaded but no indexer class found", name)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load workspace indexer '%s': %s", name, e)
|
||||
return None
|
||||
|
||||
|
||||
def _load_indexer_from_dir(indexer_dir: Path) -> Optional[type]:
|
||||
"""Import an indexer module and extract the BaseIndexer subclass.
|
||||
|
||||
The module must have either:
|
||||
- A register(ctx) function (plugin-style) — we simulate a ctx
|
||||
- A top-level class that extends BaseIndexer — we return the class
|
||||
"""
|
||||
name = indexer_dir.name
|
||||
module_name = f"plugins.workspace.{name}"
|
||||
init_file = indexer_dir / "__init__.py"
|
||||
|
||||
if not init_file.exists():
|
||||
return None
|
||||
|
||||
# Check if already loaded
|
||||
if module_name in sys.modules:
|
||||
mod = sys.modules[module_name]
|
||||
else:
|
||||
# Handle relative imports within the plugin
|
||||
# First ensure the parent packages are registered
|
||||
for parent in ("plugins", "plugins.workspace"):
|
||||
if parent not in sys.modules:
|
||||
parent_path = Path(__file__).parent
|
||||
if parent == "plugins":
|
||||
parent_path = parent_path.parent
|
||||
parent_init = parent_path / "__init__.py"
|
||||
if parent_init.exists():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
parent,
|
||||
str(parent_init),
|
||||
submodule_search_locations=[str(parent_path)],
|
||||
)
|
||||
if spec and spec.loader:
|
||||
parent_mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[parent] = parent_mod
|
||||
try:
|
||||
spec.loader.exec_module(parent_mod)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Now load the indexer module
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
str(init_file),
|
||||
submodule_search_locations=[str(indexer_dir)],
|
||||
)
|
||||
if not spec or not spec.loader:
|
||||
return None
|
||||
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = mod
|
||||
|
||||
# Register submodules so relative imports work
|
||||
for sub_file in indexer_dir.glob("*.py"):
|
||||
if sub_file.name == "__init__.py":
|
||||
continue
|
||||
sub_name = sub_file.stem
|
||||
full_sub_name = f"{module_name}.{sub_name}"
|
||||
if full_sub_name not in sys.modules:
|
||||
sub_spec = importlib.util.spec_from_file_location(
|
||||
full_sub_name, str(sub_file)
|
||||
)
|
||||
if sub_spec and sub_spec.loader:
|
||||
sub_mod = importlib.util.module_from_spec(sub_spec)
|
||||
sys.modules[full_sub_name] = sub_mod
|
||||
try:
|
||||
sub_spec.loader.exec_module(sub_mod)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to load submodule %s: %s", full_sub_name, e
|
||||
)
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to exec_module %s: %s", module_name, e)
|
||||
sys.modules.pop(module_name, None)
|
||||
return None
|
||||
|
||||
# Try register(ctx) pattern first (how plugins are written)
|
||||
if hasattr(mod, "register"):
|
||||
collector = _IndexerCollector()
|
||||
try:
|
||||
mod.register(collector)
|
||||
if collector.indexer_cls:
|
||||
return collector.indexer_cls
|
||||
except Exception as e:
|
||||
logger.debug("register() failed for %s: %s", name, e)
|
||||
|
||||
# Fallback: find a BaseIndexer subclass
|
||||
from workspace.base import BaseIndexer
|
||||
|
||||
for attr_name in dir(mod):
|
||||
attr = getattr(mod, attr_name, None)
|
||||
if (
|
||||
isinstance(attr, type)
|
||||
and issubclass(attr, BaseIndexer)
|
||||
and attr is not BaseIndexer
|
||||
):
|
||||
return attr
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class _IndexerCollector:
|
||||
"""Fake plugin context that captures register_workspace_indexer calls."""
|
||||
|
||||
def __init__(self):
|
||||
self.indexer_cls = None
|
||||
|
||||
def register_workspace_indexer(self, cls):
|
||||
self.indexer_cls = cls
|
||||
|
||||
# No-op for other registration methods
|
||||
def register_tool(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def register_hook(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def register_cli_command(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def register_memory_provider(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def register_context_engine(self, *args, **kwargs):
|
||||
pass
|
||||
@@ -1,244 +0,0 @@
|
||||
"""Semtools workspace plugin — semantic search via @llamaindex/semtools.
|
||||
|
||||
semtools is a Rust CLI that does semantic search using model2vec.
|
||||
It auto-indexes files on first search, so index() is mostly a no-op.
|
||||
"""
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from workspace.base import BaseIndexer
|
||||
from workspace.config import WorkspaceConfig
|
||||
from workspace.types import IndexSummary, SearchResult
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SemtoolsIndexer(BaseIndexer):
|
||||
def __init__(self, config: WorkspaceConfig) -> None:
|
||||
self._config = config
|
||||
pc = config.plugin_config
|
||||
self._workspace = pc.get("workspace_name", "hermes")
|
||||
self._top_k = pc.get("top_k", 20)
|
||||
|
||||
def index(self, *, progress=None) -> IndexSummary:
|
||||
"""Discover files but skip actual indexing — semtools auto-indexes on search."""
|
||||
self._ensure_semtools()
|
||||
from workspace.files import discover_workspace_files
|
||||
|
||||
discovery = discover_workspace_files(self._config)
|
||||
return IndexSummary(
|
||||
files_indexed=0,
|
||||
files_skipped=len(discovery.files),
|
||||
files_pruned=0,
|
||||
files_errored=0,
|
||||
chunks_created=0,
|
||||
duration_seconds=0.0,
|
||||
errors=[],
|
||||
errors_truncated=False,
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
limit: int = 20,
|
||||
path_prefix: str | None = None,
|
||||
file_glob: str | None = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Run semtools search against discovered workspace files."""
|
||||
self._ensure_semtools()
|
||||
from workspace.files import discover_workspace_files
|
||||
|
||||
discovery = discover_workspace_files(self._config)
|
||||
files = [str(p) for _, p in discovery.files]
|
||||
|
||||
if path_prefix:
|
||||
files = [f for f in files if f.startswith(path_prefix)]
|
||||
if file_glob:
|
||||
pattern = file_glob if file_glob.startswith("*") else "*" + file_glob
|
||||
files = [f for f in files if fnmatch.fnmatch(f, pattern)]
|
||||
|
||||
if not files:
|
||||
return []
|
||||
|
||||
cmd = [
|
||||
"semtools",
|
||||
"search",
|
||||
query,
|
||||
*files,
|
||||
"--json",
|
||||
"--top-k",
|
||||
str(limit),
|
||||
"--workspace",
|
||||
self._workspace,
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
log.error("semtools search failed: %s", e.stderr)
|
||||
raise RuntimeError(f"semtools search failed: {e.stderr}") from e
|
||||
except FileNotFoundError as e:
|
||||
raise RuntimeError(
|
||||
"semtools binary not found. Install with: npm i -g @llamaindex/semtools"
|
||||
) from e
|
||||
|
||||
return self._parse_results(result.stdout)
|
||||
|
||||
def status(self) -> dict:
|
||||
installed = shutil.which("semtools") is not None
|
||||
info: dict = {
|
||||
"backend": "semtools",
|
||||
"installed": installed,
|
||||
"workspace_name": self._workspace,
|
||||
}
|
||||
if not installed:
|
||||
return info
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["semtools", "workspace", "status", "--json", self._workspace],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
ws_info = json.loads(result.stdout)
|
||||
info["root_dir"] = ws_info.get("root_dir")
|
||||
info["total_documents"] = ws_info.get("total_documents", 0)
|
||||
except (subprocess.CalledProcessError, json.JSONDecodeError) as e:
|
||||
log.debug("semtools workspace status failed: %s", e)
|
||||
return info
|
||||
|
||||
def list_files(self) -> list[dict]:
|
||||
"""List files discoverable under configured roots.
|
||||
|
||||
Semtools auto-indexes on search, so this returns the discovery set
|
||||
that WOULD be indexed rather than what's actually in the embedding store.
|
||||
"""
|
||||
from workspace.files import discover_workspace_files
|
||||
|
||||
discovery = discover_workspace_files(self._config)
|
||||
return [
|
||||
{
|
||||
"path": str(p),
|
||||
"root": str(root),
|
||||
"size_bytes": p.stat().st_size if p.exists() else 0,
|
||||
"chunks": 0,
|
||||
"modified": "",
|
||||
"indexed": "",
|
||||
}
|
||||
for root, p in discovery.files
|
||||
]
|
||||
|
||||
def delete(self, path: str) -> bool:
|
||||
"""Semtools doesn't expose per-file delete; runs workspace prune instead.
|
||||
|
||||
Prune removes stale entries (files that no longer exist on disk).
|
||||
Returns True if the file is gone from disk AND prune succeeded.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
if Path(path).exists():
|
||||
return False
|
||||
try:
|
||||
subprocess.run(
|
||||
["semtools", "workspace", "prune", self._workspace],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
log.warning("semtools workspace prune failed: %s", e.stderr)
|
||||
return False
|
||||
|
||||
def _ensure_semtools(self) -> None:
|
||||
"""Install semtools if not already present (idempotent)."""
|
||||
if shutil.which("semtools"):
|
||||
return
|
||||
if not shutil.which("npm"):
|
||||
raise RuntimeError(
|
||||
"npm is required to install semtools. Install Node.js first."
|
||||
)
|
||||
try:
|
||||
subprocess.run(
|
||||
["npm", "i", "-g", "@llamaindex/semtools"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(f"Failed to install semtools via npm: {e.stderr}") from e
|
||||
if not shutil.which("semtools"):
|
||||
raise RuntimeError(
|
||||
"semtools installed but not found on PATH after npm install"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_results(stdout: str) -> list[SearchResult]:
|
||||
"""Parse semtools JSON output into SearchResult objects.
|
||||
|
||||
semtools outputs::
|
||||
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"filename": "/path/to/file.py",
|
||||
"start_line_number": 0,
|
||||
"end_line_number": 7,
|
||||
"match_line_number": 3,
|
||||
"distance": 0.219,
|
||||
"content": "..."
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Distance is a dissimilarity metric (lower = better match).
|
||||
We convert to a similarity score: score = 1.0 - distance.
|
||||
Line numbers from semtools are 0-based; we convert to 1-based.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
except json.JSONDecodeError:
|
||||
log.warning("Failed to parse semtools JSON output")
|
||||
return []
|
||||
|
||||
results_raw = data.get("results", [])
|
||||
results: list[SearchResult] = []
|
||||
|
||||
for i, item in enumerate(results_raw):
|
||||
distance = item.get("distance", 1.0)
|
||||
score = max(0.0, 1.0 - distance)
|
||||
|
||||
start_line = item.get("start_line_number", 0) + 1
|
||||
end_line = item.get("end_line_number", 0) + 1
|
||||
content = item.get("content", "")
|
||||
|
||||
results.append(
|
||||
SearchResult(
|
||||
path=item.get("filename", ""),
|
||||
line_start=start_line,
|
||||
line_end=end_line,
|
||||
section=None,
|
||||
chunk_index=i,
|
||||
score=round(score, 6),
|
||||
tokens=0,
|
||||
modified="",
|
||||
content=content,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_workspace_indexer(SemtoolsIndexer)
|
||||
+1
-7
@@ -34,10 +34,6 @@ dependencies = [
|
||||
"edge-tts>=7.2.7,<8",
|
||||
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
|
||||
"PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597
|
||||
# Workspace .hermesignore parsing (gitignore-style patterns)
|
||||
"pathspec>=0.12.0,<1",
|
||||
# Workspace encoding detection for non-UTF8 files
|
||||
"charset-normalizer>=3.3.0,<4",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -68,8 +64,6 @@ sms = ["aiohttp>=3.9.0,<4"]
|
||||
acp = ["agent-client-protocol>=0.9.0,<1.0"]
|
||||
mistral = ["mistralai>=2.3.0,<3"]
|
||||
bedrock = ["boto3>=1.35.0,<2"]
|
||||
workspace = ["chonkie[code]>=1.6.0,<2", "hermes-agent[parsing]"]
|
||||
parsing = ["markitdown[pdf,docx,pptx]>=0.1.0"]
|
||||
termux = [
|
||||
# Tested Android / Termux path: keeps the core CLI feature-rich while
|
||||
# avoiding extras that currently depend on non-Android wheels (notably
|
||||
@@ -132,7 +126,7 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector
|
||||
hermes_cli = ["web_dist/**/*"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "workspace"]
|
||||
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
+345
-686
File diff suppressed because it is too large
Load Diff
+26
-1
@@ -80,6 +80,13 @@ AUTHOR_MAP = {
|
||||
"nish3451@users.noreply.github.com": "nish3451",
|
||||
"Mibayy@users.noreply.github.com": "Mibayy",
|
||||
"135070653+sgaofen@users.noreply.github.com": "sgaofen",
|
||||
"nocoo@users.noreply.github.com": "nocoo",
|
||||
"30841158+n-WN@users.noreply.github.com": "n-WN",
|
||||
"leoyuan0099@gmail.com": "keyuyuan",
|
||||
"bxzt2006@163.com": "Only-Code-A",
|
||||
"i@troy-y.org": "TroyMitchell911",
|
||||
"mygamez@163.com": "zhongyueming1121",
|
||||
"hansnow@users.noreply.github.com": "hansnow",
|
||||
# contributors (manual mapping from git names)
|
||||
"ahmedsherif95@gmail.com": "asheriif",
|
||||
"liujinkun@bytedance.com": "liujinkun2025",
|
||||
@@ -97,14 +104,21 @@ AUTHOR_MAP = {
|
||||
"xiewenxuan462@gmail.com": "yule975",
|
||||
"yiweimeng.dlut@hotmail.com": "meng93",
|
||||
"hakanerten02@hotmail.com": "teyrebaz33",
|
||||
"linux2010@users.noreply.github.com": "Linux2010",
|
||||
"elmatadorgh@users.noreply.github.com": "elmatadorgh",
|
||||
"alexazzjjtt@163.com": "alexzhu0",
|
||||
"ruzzgarcn@gmail.com": "Ruzzgar",
|
||||
"alireza78.crypto@gmail.com": "alireza78a",
|
||||
"brooklyn.bb.nicholson@gmail.com": "brooklynnicholson",
|
||||
"withapurpose37@gmail.com": "StefanIsMe",
|
||||
"4317663+helix4u@users.noreply.github.com": "helix4u",
|
||||
"331214+counterposition@users.noreply.github.com": "counterposition",
|
||||
"blspear@gmail.com": "BrennerSpear",
|
||||
"akhater@gmail.com": "akhater",
|
||||
"239876380+handsdiff@users.noreply.github.com": "handsdiff",
|
||||
"hesapacicam112@gmail.com": "etherman-os",
|
||||
"mark.ramsell@rivermounts.com": "mark-ramsell",
|
||||
"taeng02@icloud.com": "taeng0204",
|
||||
"gpickett00@gmail.com": "gpickett00",
|
||||
"mcosma@gmail.com": "wakamex",
|
||||
"clawdia.nash@proton.me": "clawdia-nash",
|
||||
@@ -115,6 +129,7 @@ AUTHOR_MAP = {
|
||||
"noonou7@gmail.com": "HenkDz",
|
||||
"dean.kerr@gmail.com": "deankerr",
|
||||
"socrates1024@gmail.com": "socrates1024",
|
||||
"seanalt555@gmail.com": "Salt-555",
|
||||
"satelerd@gmail.com": "satelerd",
|
||||
"numman.ali@gmail.com": "nummanali",
|
||||
"0xNyk@users.noreply.github.com": "0xNyk",
|
||||
@@ -126,12 +141,14 @@ AUTHOR_MAP = {
|
||||
"aryan@synvoid.com": "aryansingh",
|
||||
"johnsonblake1@gmail.com": "blakejohnson",
|
||||
"hcn518@gmail.com": "pedh",
|
||||
"haileymarshall005@gmail.com": "haileymarshall",
|
||||
"greer.guthrie@gmail.com": "g-guthrie",
|
||||
"kennyx102@gmail.com": "bobashopcashier",
|
||||
"shokatalishaikh95@gmail.com": "areu01or00",
|
||||
"bryan@intertwinesys.com": "bryanyoung",
|
||||
"christo.mitov@gmail.com": "christomitov",
|
||||
"hermes@nousresearch.com": "NousResearch",
|
||||
"hermes@noushq.ai": "benbarclay",
|
||||
"chinmingcock@gmail.com": "ChimingLiu",
|
||||
"openclaw@sparklab.ai": "openclaw",
|
||||
"semihcvlk53@gmail.com": "Himess",
|
||||
@@ -146,7 +163,7 @@ AUTHOR_MAP = {
|
||||
"jack.47@gmail.com": "JackTheGit",
|
||||
"dalvidjr2022@gmail.com": "Jr-kenny",
|
||||
"m@statecraft.systems": "mbierling",
|
||||
"balyan.sid@gmail.com": "balyansid",
|
||||
"balyan.sid@gmail.com": "alt-glitch",
|
||||
"oluwadareab12@gmail.com": "bennytimz",
|
||||
"simon@simonmarcus.org": "simon-marcus",
|
||||
"xowiekk@gmail.com": "Xowiek",
|
||||
@@ -198,6 +215,7 @@ AUTHOR_MAP = {
|
||||
"kagura.chen28@gmail.com": "kagura-agent",
|
||||
"1342088860@qq.com": "youngDoo",
|
||||
"kamil@gwozdz.me": "kamil-gwozdz",
|
||||
"skmishra1991@gmail.com": "bugkill3r",
|
||||
"karamusti912@gmail.com": "MustafaKara7",
|
||||
"kira@ariaki.me": "kira-ariaki",
|
||||
"knopki@duck.com": "knopki",
|
||||
@@ -208,6 +226,7 @@ AUTHOR_MAP = {
|
||||
"82095453+iacker@users.noreply.github.com": "iacker",
|
||||
"sontianye@users.noreply.github.com": "sontianye",
|
||||
"jackjin1997@users.noreply.github.com": "jackjin1997",
|
||||
"1037461232@qq.com": "jackjin1997",
|
||||
"danieldoderlein@users.noreply.github.com": "danieldoderlein",
|
||||
"lrawnsley@users.noreply.github.com": "lrawnsley",
|
||||
"taeuk178@users.noreply.github.com": "taeuk178",
|
||||
@@ -216,6 +235,7 @@ AUTHOR_MAP = {
|
||||
"ygd58@users.noreply.github.com": "ygd58",
|
||||
"vominh1919@users.noreply.github.com": "vominh1919",
|
||||
"iamagenius00@users.noreply.github.com": "iamagenius00",
|
||||
"9219265+cresslank@users.noreply.github.com": "cresslank",
|
||||
"trevmanthony@gmail.com": "trevthefoolish",
|
||||
"ziliangpeng@users.noreply.github.com": "ziliangpeng",
|
||||
"centripetal-star@users.noreply.github.com": "centripetal-star",
|
||||
@@ -273,10 +293,15 @@ AUTHOR_MAP = {
|
||||
"asurla@nvidia.com": "anniesurla",
|
||||
"limkuan24@gmail.com": "WideLee",
|
||||
"aviralarora002@gmail.com": "AviArora02-commits",
|
||||
"draixagent@gmail.com": "draix",
|
||||
"junminliu@gmail.com": "JimLiu",
|
||||
"jarvischer@gmail.com": "maxchernin",
|
||||
"levantam.98.2324@gmail.com": "LVT382009",
|
||||
"zhurongcheng@rcrai.com": "heykb",
|
||||
"withapurpose37@gmail.com": "StefanIsMe",
|
||||
"261797239+lumenradley@users.noreply.github.com": "lumenradley",
|
||||
"166376523+sjz-ks@users.noreply.github.com": "sjz-ks",
|
||||
"haileymarshall005@gmail.com": "haileymarshall",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -338,7 +338,6 @@ Edit with `hermes config edit` or `hermes config set section.key value`.
|
||||
| `memory` | `memory_enabled`, `user_profile_enabled`, `provider` |
|
||||
| `security` | `tirith_enabled`, `website_blocklist` |
|
||||
| `delegation` | `model`, `provider`, `base_url`, `api_key`, `max_iterations` (50), `reasoning_effort` |
|
||||
| `smart_model_routing` | `enabled`, `cheap_model` |
|
||||
| `checkpoints` | `enabled`, `max_snapshots` (50) |
|
||||
|
||||
Full config reference: https://hermes-agent.nousresearch.com/docs/user-guide/configuration
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Attribution
|
||||
|
||||
This skill bundles code ported from a third-party MIT-licensed project.
|
||||
All reuse is credited here.
|
||||
|
||||
## pixel-art-studio (Synero)
|
||||
|
||||
- Source: https://github.com/Synero/pixel-art-studio
|
||||
- License: MIT
|
||||
- Copyright: © Synero, MIT-licensed contributors
|
||||
|
||||
### What was ported
|
||||
|
||||
**`scripts/palettes.py`** — the `PALETTES` dict containing 23 named RGB
|
||||
palettes (hardware and artistic). Values are reproduced verbatim from
|
||||
`scripts/pixelart.py` of pixel-art-studio.
|
||||
|
||||
**`scripts/pixel_art_video.py`** — the 12 procedural animation init/draw pairs
|
||||
(`stars`, `fireflies`, `leaves`, `dust_motes`, `sparkles`, `rain`,
|
||||
`lightning`, `bubbles`, `embers`, `snowflakes`, `neon_pulse`, `heat_shimmer`)
|
||||
and the `SCENES` → layer mapping. Ported from `scripts/pixelart_video.py`
|
||||
with minor refactors:
|
||||
- Names prefixed with `_` for private helpers (`_px`, `_pixel_cross`)
|
||||
- `SCENE_ANIMATIONS` renamed to `SCENES` and restructured to hold layer
|
||||
names (strings) instead of function-name strings resolved via `globals()`
|
||||
- `generate_video()` split: the Pollinations text-to-image call was removed
|
||||
(Hermes uses its own `image_generate` + `pixel_art()` pipeline for base
|
||||
frames). Only the overlay + ffmpeg encoding remains.
|
||||
- Frame directory is now a `tempfile.TemporaryDirectory` instead of
|
||||
hand-managed cleanup.
|
||||
- `ffmpeg` invocation switched from `os.system` to `subprocess.run(check=True)`
|
||||
for safety.
|
||||
|
||||
### What was NOT ported
|
||||
|
||||
- Wu's Color Quantization (PIL's built-in `quantize` suffices)
|
||||
- Sobel edge-aware downsampling (requires scipy; not worth the dep)
|
||||
- Bayer / Atkinson dither (would need numpy reimplementation; kept scope tight)
|
||||
- Pollinations text-to-image generation (`pixelart_image.py`,
|
||||
`generate_base()` in `pixelart_video.py`) — Hermes has `image_generate`
|
||||
|
||||
### License compatibility
|
||||
|
||||
pixel-art-studio ships under the MIT License, which permits redistribution
|
||||
with attribution. This skill preserves the original copyright notice here
|
||||
and in the SKILL.md credits block. No code was relicensed.
|
||||
|
||||
---
|
||||
|
||||
## pixel-art skill itself
|
||||
|
||||
- License: MIT (inherits from hermes-agent repo)
|
||||
- Original author of the skill shell: dodo-reach
|
||||
- Expansion with palettes + video: Hermes Agent contributors
|
||||
+178
-131
@@ -1,170 +1,217 @@
|
||||
---
|
||||
name: pixel-art
|
||||
description: Convert images into retro pixel art using named presets (arcade, snes) with Floyd-Steinberg dithering. Arcade is bold and chunky; SNES is cleaner with more detail retention.
|
||||
version: 1.2.0
|
||||
description: Convert images into retro pixel art with hardware-accurate palettes (NES, Game Boy, PICO-8, C64, etc.), and animate them into short videos. Presets cover arcade, SNES, and 10+ era-correct looks. Use `clarify` to let the user pick a style before generating.
|
||||
version: 2.0.0
|
||||
author: dodo-reach
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [creative, pixel-art, arcade, snes, retro, image]
|
||||
tags: [creative, pixel-art, arcade, snes, nes, gameboy, retro, image, video]
|
||||
category: creative
|
||||
credits:
|
||||
- "Hardware palettes and animation loops ported from Synero/pixel-art-studio (MIT) — https://github.com/Synero/pixel-art-studio"
|
||||
---
|
||||
|
||||
# Pixel Art
|
||||
|
||||
Convert any image into retro-style pixel art. One function with named presets that select different aesthetics:
|
||||
Convert any image into retro pixel art, then optionally animate it into a short
|
||||
MP4 or GIF with era-appropriate effects (rain, fireflies, snow, embers).
|
||||
|
||||
- `arcade` — 16-color palette, 8px blocks. Bold, chunky, high-impact. 80s/90s arcade cabinet feel.
|
||||
- `snes` — 32-color palette, 4px blocks. Cleaner 16-bit console look with more detail retention.
|
||||
Two scripts ship with this skill:
|
||||
|
||||
The core pipeline is identical across presets — what changes is palette size, block size, and the strength of contrast/color/posterize pre-processing. All presets use Floyd-Steinberg dithering applied AFTER downscale so error diffusion aligns with the final pixel grid.
|
||||
- `scripts/pixel_art.py` — photo → pixel-art PNG (Floyd-Steinberg dithering)
|
||||
- `scripts/pixel_art_video.py` — pixel-art PNG → animated MP4 (+ optional GIF)
|
||||
|
||||
Each is importable or runnable directly. Presets snap to hardware palettes
|
||||
when you want era-accurate colors (NES, Game Boy, PICO-8, etc.), or use
|
||||
adaptive N-color quantization for arcade/SNES-style looks.
|
||||
|
||||
## When to Use
|
||||
|
||||
- User wants retro pixel art from a source image
|
||||
- Posters, album covers, social posts, sprites, characters, backgrounds
|
||||
- Subject can tolerate aggressive simplification (arcade) or benefits from retained detail (snes)
|
||||
- User asks for NES / Game Boy / PICO-8 / C64 / arcade / SNES styling
|
||||
- User wants a short looping animation (rain scene, night sky, snow, etc.)
|
||||
- Posters, album covers, social posts, sprites, characters, avatars
|
||||
|
||||
## Preset Picker
|
||||
## Workflow
|
||||
|
||||
| Preset | Palette | Block | Best for |
|
||||
|--------|---------|-------|----------|
|
||||
| `arcade` | 16 colors | 8px | Posters, hero images, bold covers, simple subjects |
|
||||
| `snes` | 32 colors | 4px | Characters, sprites, detailed illustrations, photos |
|
||||
Before generating, confirm the style with the user. Different presets produce
|
||||
very different outputs and regenerating is costly.
|
||||
|
||||
Default is `arcade` for maximum stylistic punch. Switch to `snes` when the subject has detail worth preserving.
|
||||
### Step 1 — Offer a style
|
||||
|
||||
## Procedure
|
||||
Call `clarify` with 4 representative presets. Pick the set based on what the
|
||||
user asked for — don't just dump all 14.
|
||||
|
||||
1. Pick a preset (`arcade` or `snes`) based on the aesthetic you want.
|
||||
2. Boost contrast, color, and sharpness using the preset's enhancement values.
|
||||
3. Lightly posterize the image to simplify tonal regions before quantization.
|
||||
4. Downscale to `w // block` by `h // block` with `Image.NEAREST`.
|
||||
5. Quantize the reduced image to the preset's palette size with Floyd-Steinberg dithering.
|
||||
6. Upscale back to the original size with `Image.NEAREST`.
|
||||
7. Save the output as PNG.
|
||||
|
||||
## Code
|
||||
Default menu when the user's intent is unclear:
|
||||
|
||||
```python
|
||||
from PIL import Image, ImageEnhance, ImageOps
|
||||
|
||||
PRESETS = {
|
||||
"arcade": {
|
||||
"contrast": 1.8,
|
||||
"color": 1.5,
|
||||
"sharpness": 1.2,
|
||||
"posterize_bits": 5,
|
||||
"block": 8,
|
||||
"palette": 16,
|
||||
},
|
||||
"snes": {
|
||||
"contrast": 1.6,
|
||||
"color": 1.4,
|
||||
"sharpness": 1.2,
|
||||
"posterize_bits": 6,
|
||||
"block": 4,
|
||||
"palette": 32,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def pixel_art(input_path, output_path, preset="arcade", **overrides):
|
||||
"""
|
||||
Convert an image to retro pixel art.
|
||||
|
||||
Args:
|
||||
input_path: path to source image
|
||||
output_path: path to save the resulting PNG
|
||||
preset: "arcade" or "snes"
|
||||
**overrides: optionally override any preset field
|
||||
(contrast, color, sharpness, posterize_bits, block, palette)
|
||||
|
||||
Returns:
|
||||
The resulting PIL.Image.
|
||||
"""
|
||||
if preset not in PRESETS:
|
||||
raise ValueError(
|
||||
f"Unknown preset {preset!r}. Choose from: {sorted(PRESETS)}"
|
||||
)
|
||||
|
||||
cfg = {**PRESETS[preset], **overrides}
|
||||
|
||||
img = Image.open(input_path).convert("RGB")
|
||||
|
||||
# Stylistic boost — stronger for smaller palettes
|
||||
img = ImageEnhance.Contrast(img).enhance(cfg["contrast"])
|
||||
img = ImageEnhance.Color(img).enhance(cfg["color"])
|
||||
img = ImageEnhance.Sharpness(img).enhance(cfg["sharpness"])
|
||||
|
||||
# Light posterization separates tonal regions before quantization
|
||||
img = ImageOps.posterize(img, cfg["posterize_bits"])
|
||||
|
||||
w, h = img.size
|
||||
block = cfg["block"]
|
||||
small = img.resize(
|
||||
(max(1, w // block), max(1, h // block)),
|
||||
Image.NEAREST,
|
||||
)
|
||||
|
||||
# Quantize AFTER downscaling so dithering aligns with the final pixel grid
|
||||
quantized = small.quantize(
|
||||
colors=cfg["palette"], dither=Image.FLOYDSTEINBERG
|
||||
)
|
||||
result = quantized.resize((w, h), Image.NEAREST)
|
||||
|
||||
result.save(output_path, "PNG")
|
||||
return result
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
|
||||
```python
|
||||
# Bold arcade look (default)
|
||||
pixel_art("/path/to/image.jpg", "/path/to/arcade.png")
|
||||
|
||||
# Cleaner SNES look with more detail
|
||||
pixel_art("/path/to/image.jpg", "/path/to/snes.png", preset="snes")
|
||||
|
||||
# Override individual parameters — e.g. tighter palette with SNES block size
|
||||
pixel_art(
|
||||
"/path/to/image.jpg",
|
||||
"/path/to/custom.png",
|
||||
preset="snes",
|
||||
palette=16,
|
||||
clarify(
|
||||
question="Which pixel-art style do you want?",
|
||||
choices=[
|
||||
"arcade — bold, chunky 80s cabinet feel (16 colors, 8px)",
|
||||
"nes — Nintendo 8-bit hardware palette (54 colors, 8px)",
|
||||
"gameboy — 4-shade green Game Boy DMG",
|
||||
"snes — cleaner 16-bit look (32 colors, 4px)",
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
## Why This Order Works
|
||||
When the user already named an era (e.g. "80s arcade", "Gameboy"), skip
|
||||
`clarify` and use the matching preset directly.
|
||||
|
||||
Floyd-Steinberg dithering distributes quantization error to adjacent pixels. Applying it AFTER downscaling keeps that error diffusion aligned with the reduced pixel grid, so each dithered pixel maps cleanly to a final enlarged block. Quantizing before downscaling wastes the dithering pattern on full-resolution detail that disappears during resize.
|
||||
### Step 2 — Offer animation (optional)
|
||||
|
||||
A light posterization step before downscaling improves separation between tonal regions, which helps photographic inputs read as stylized pixel art instead of simple pixelated photos.
|
||||
If the user asked for a video/GIF, or the output might benefit from motion,
|
||||
ask which scene:
|
||||
|
||||
Stronger pre-processing (higher contrast/color) pairs with smaller palettes because fewer colors have to carry the whole image. SNES runs softer enhancements because 32 colors can represent gradients and mid-tones directly.
|
||||
```python
|
||||
clarify(
|
||||
question="Want to animate it? Pick a scene or skip.",
|
||||
choices=[
|
||||
"night — stars + fireflies + leaves",
|
||||
"urban — rain + neon pulse",
|
||||
"snow — falling snowflakes",
|
||||
"skip — just the image",
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
Do NOT call `clarify` more than twice in a row. One for style, one for scene if
|
||||
animation is on the table. If the user explicitly asked for a specific style
|
||||
and scene in their message, skip `clarify` entirely.
|
||||
|
||||
- `arcade` 8px blocks are aggressive and can destroy fine detail — use `snes` for subjects that need retention
|
||||
- Busy photographs can become noisy under `snes` because the larger palette preserves small variations — use `arcade` to flatten them
|
||||
- Very small source images (<~100px wide) may collapse under 8px blocks. `max(1, w // block)` guards against zero dimensions, but output will be visually degenerate.
|
||||
- Fractional overrides for `block` or `palette` will break quantization — keep them as positive integers.
|
||||
### Step 3 — Generate
|
||||
|
||||
## Verification
|
||||
Run `pixel_art()` first; if animation was requested, chain into
|
||||
`pixel_art_video()` on the result.
|
||||
|
||||
Output is correct if:
|
||||
## Preset Catalog
|
||||
|
||||
- A PNG file is created at the output path
|
||||
- The image shows clear square pixel blocks at the preset's block size
|
||||
- Dithering is visible in gradients
|
||||
- The palette is limited to approximately the preset's color count
|
||||
- The overall look matches the targeted era (arcade or SNES)
|
||||
| Preset | Era | Palette | Block | Best for |
|
||||
|--------|-----|---------|-------|----------|
|
||||
| `arcade` | 80s arcade | adaptive 16 | 8px | Bold posters, hero art |
|
||||
| `snes` | 16-bit | adaptive 32 | 4px | Characters, detailed scenes |
|
||||
| `nes` | 8-bit | NES (54) | 8px | True NES look |
|
||||
| `gameboy` | DMG handheld | 4 green shades | 8px | Monochrome Game Boy |
|
||||
| `gameboy_pocket` | Pocket handheld | 4 grey shades | 8px | Mono GB Pocket |
|
||||
| `pico8` | PICO-8 | 16 fixed | 6px | Fantasy-console look |
|
||||
| `c64` | Commodore 64 | 16 fixed | 8px | 8-bit home computer |
|
||||
| `apple2` | Apple II hi-res | 6 fixed | 10px | Extreme retro, 6 colors |
|
||||
| `teletext` | BBC Teletext | 8 pure | 10px | Chunky primary colors |
|
||||
| `mspaint` | Windows MS Paint | 24 fixed | 8px | Nostalgic desktop |
|
||||
| `mono_green` | CRT phosphor | 2 green | 6px | Terminal/CRT aesthetic |
|
||||
| `mono_amber` | CRT amber | 2 amber | 6px | Amber monitor look |
|
||||
| `neon` | Cyberpunk | 10 neons | 6px | Vaporwave/cyber |
|
||||
| `pastel` | Soft pastel | 10 pastels | 6px | Kawaii / gentle |
|
||||
|
||||
Named palettes live in `scripts/palettes.py` (see `references/palettes.md` for
|
||||
the complete list — 28 named palettes total). Any preset can be overridden:
|
||||
|
||||
```python
|
||||
pixel_art("in.png", "out.png", preset="snes", palette="PICO_8", block=6)
|
||||
```
|
||||
|
||||
## Scene Catalog (for video)
|
||||
|
||||
| Scene | Effects |
|
||||
|-------|---------|
|
||||
| `night` | Twinkling stars + fireflies + drifting leaves |
|
||||
| `dusk` | Fireflies + sparkles |
|
||||
| `tavern` | Dust motes + warm sparkles |
|
||||
| `indoor` | Dust motes |
|
||||
| `urban` | Rain + neon pulse |
|
||||
| `nature` | Leaves + fireflies |
|
||||
| `magic` | Sparkles + fireflies |
|
||||
| `storm` | Rain + lightning |
|
||||
| `underwater` | Bubbles + light sparkles |
|
||||
| `fire` | Embers + sparkles |
|
||||
| `snow` | Snowflakes + sparkles |
|
||||
| `desert` | Heat shimmer + dust |
|
||||
|
||||
## Invocation Patterns
|
||||
|
||||
### Python (import)
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "/home/teknium/.hermes/skills/creative/pixel-art/scripts")
|
||||
from pixel_art import pixel_art
|
||||
from pixel_art_video import pixel_art_video
|
||||
|
||||
# 1. Convert to pixel art
|
||||
pixel_art("/path/to/photo.jpg", "/tmp/pixel.png", preset="nes")
|
||||
|
||||
# 2. Animate (optional)
|
||||
pixel_art_video(
|
||||
"/tmp/pixel.png",
|
||||
"/tmp/pixel.mp4",
|
||||
scene="night",
|
||||
duration=6,
|
||||
fps=15,
|
||||
seed=42,
|
||||
export_gif=True,
|
||||
)
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
cd /home/teknium/.hermes/skills/creative/pixel-art/scripts
|
||||
|
||||
python pixel_art.py in.jpg out.png --preset gameboy
|
||||
python pixel_art.py in.jpg out.png --preset snes --palette PICO_8 --block 6
|
||||
|
||||
python pixel_art_video.py out.png out.mp4 --scene night --duration 6 --gif
|
||||
```
|
||||
|
||||
## Pipeline Rationale
|
||||
|
||||
**Pixel conversion:**
|
||||
1. Boost contrast/color/sharpness (stronger for smaller palettes)
|
||||
2. Posterize to simplify tonal regions before quantization
|
||||
3. Downscale by `block` with `Image.NEAREST` (hard pixels, no interpolation)
|
||||
4. Quantize with Floyd-Steinberg dithering — against either an adaptive
|
||||
N-color palette OR a named hardware palette
|
||||
5. Upscale back with `Image.NEAREST`
|
||||
|
||||
Quantizing AFTER downscale keeps dithering aligned with the final pixel grid.
|
||||
Quantizing before would waste error-diffusion on detail that disappears.
|
||||
|
||||
**Video overlay:**
|
||||
- Copies the base frame each tick (static background)
|
||||
- Overlays stateless-per-frame particle draws (one function per effect)
|
||||
- Encodes via ffmpeg `libx264 -pix_fmt yuv420p -crf 18`
|
||||
- Optional GIF via `palettegen` + `paletteuse`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Python 3
|
||||
- Pillow
|
||||
- Python 3.9+
|
||||
- Pillow (`pip install Pillow`)
|
||||
- ffmpeg on PATH (only needed for video — Hermes installs package this)
|
||||
|
||||
```bash
|
||||
pip install Pillow
|
||||
```
|
||||
## Pitfalls
|
||||
|
||||
- Pallet keys are case-sensitive (`"NES"`, `"PICO_8"`, `"GAMEBOY_ORIGINAL"`).
|
||||
- Very small sources (<100px wide) collapse under 8-10px blocks. Upscale the
|
||||
source first if it's tiny.
|
||||
- Fractional `block` or `palette` will break quantization — keep them positive ints.
|
||||
- Animation particle counts are tuned for ~640x480 canvases. On very large
|
||||
images you may want a second pass with a different seed for density.
|
||||
- `mono_green` / `mono_amber` force `color=0.0` (desaturate). If you override
|
||||
and keep chroma, the 2-color palette can produce stripes on smooth regions.
|
||||
- `clarify` loop: call it at most twice per turn (style, then scene). Don't
|
||||
pepper the user with more picks.
|
||||
|
||||
## Verification
|
||||
|
||||
- PNG is created at the output path
|
||||
- Clear square pixel blocks visible at the preset's block size
|
||||
- Color count matches preset (eyeball the image or run `Image.open(p).getcolors()`)
|
||||
- Video is a valid MP4 (`ffprobe` can open it) with non-zero size
|
||||
|
||||
## Attribution
|
||||
|
||||
Named hardware palettes and the procedural animation loops in `pixel_art_video.py`
|
||||
are ported from [pixel-art-studio](https://github.com/Synero/pixel-art-studio)
|
||||
(MIT). See `ATTRIBUTION.md` in this skill directory for details.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Named Palettes
|
||||
|
||||
28 hardware-accurate and artistic palettes available to `pixel_art()`.
|
||||
Palette values are sourced from `pixel-art-studio` (MIT) — see ATTRIBUTION.md in the skill root.
|
||||
|
||||
Usage: pass the palette name as `palette=` or let a preset select it.
|
||||
|
||||
```python
|
||||
pixel_art("in.png", "out.png", preset="nes") # preset selects NES
|
||||
pixel_art("in.png", "out.png", preset="custom", palette="PICO_8", block=6)
|
||||
```
|
||||
|
||||
## Hardware Palettes
|
||||
|
||||
| Name | Colors | Source |
|
||||
|------|--------|--------|
|
||||
| `NES` | 54 | Nintendo NES |
|
||||
| `C64` | 16 | Commodore 64 |
|
||||
| `COMMODORE_64` | 16 | Commodore 64 (alt) |
|
||||
| `ZX_SPECTRUM` | 8 | Sinclair ZX Spectrum |
|
||||
| `APPLE_II_LO` | 16 | Apple II lo-res |
|
||||
| `APPLE_II_HI` | 6 | Apple II hi-res |
|
||||
| `GAMEBOY_ORIGINAL` | 4 | Game Boy DMG (green) |
|
||||
| `GAMEBOY_POCKET` | 4 | Game Boy Pocket (grey) |
|
||||
| `GAMEBOY_VIRTUALBOY` | 4 | Virtual Boy (red) |
|
||||
| `PICO_8` | 16 | PICO-8 fantasy console |
|
||||
| `TELETEXT` | 8 | BBC Teletext |
|
||||
| `CGA_MODE4_PAL1` | 4 | IBM CGA |
|
||||
| `MSX` | 15 | MSX |
|
||||
| `MICROSOFT_WINDOWS_16` | 16 | Windows 3.x default |
|
||||
| `MICROSOFT_WINDOWS_PAINT` | 24 | MS Paint classic |
|
||||
| `MONO_BW` | 2 | Black and white |
|
||||
| `MONO_AMBER` | 2 | Amber monochrome |
|
||||
| `MONO_GREEN` | 2 | Green monochrome |
|
||||
|
||||
## Artistic Palettes
|
||||
|
||||
| Name | Colors | Feel |
|
||||
|------|--------|------|
|
||||
| `PASTEL_DREAM` | 10 | Soft pastels |
|
||||
| `NEON_CYBER` | 10 | Cyberpunk neon |
|
||||
| `RETRO_WARM` | 10 | Warm 70s |
|
||||
| `OCEAN_DEEP` | 10 | Blue gradient |
|
||||
| `FOREST_MOSS` | 10 | Green naturals |
|
||||
| `SUNSET_FIRE` | 10 | Red to yellow |
|
||||
| `ARCTIC_ICE` | 10 | Cool blues and whites |
|
||||
| `VINTAGE_ROSE` | 10 | Rose mauves |
|
||||
| `EARTH_CLAY` | 10 | Terracotta browns |
|
||||
| `ELECTRIC_VIOLET` | 10 | Violet gradient |
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Named RGB palettes for pixel_art() and pixel_art_video().
|
||||
|
||||
Palette RGB values sourced from pixel-art-studio (MIT License)
|
||||
https://github.com/Synero/pixel-art-studio — see ATTRIBUTION.md.
|
||||
"""
|
||||
|
||||
PALETTES = {
|
||||
# ── Hardware palettes ───────────────────────────────────────────────
|
||||
"NES": [
|
||||
(0, 0, 0), (124, 124, 124), (0, 0, 252), (0, 0, 188), (68, 40, 188),
|
||||
(148, 0, 132), (168, 0, 32), (168, 16, 0), (136, 20, 0), (0, 116, 0),
|
||||
(0, 148, 0), (0, 120, 0), (0, 88, 0), (0, 64, 88), (188, 188, 188),
|
||||
(0, 120, 248), (0, 88, 248), (104, 68, 252), (216, 0, 204), (228, 0, 88),
|
||||
(248, 56, 0), (228, 92, 16), (172, 124, 0), (0, 184, 0), (0, 168, 0),
|
||||
(0, 168, 68), (0, 136, 136), (248, 248, 248), (60, 188, 252),
|
||||
(104, 136, 252), (152, 120, 248), (248, 120, 248), (248, 88, 152),
|
||||
(248, 120, 88), (252, 160, 68), (248, 184, 0), (184, 248, 24),
|
||||
(88, 216, 84), (88, 248, 152), (0, 232, 216), (120, 120, 120),
|
||||
(252, 252, 252), (164, 228, 252), (184, 184, 248), (216, 184, 248),
|
||||
(248, 184, 248), (248, 164, 192), (240, 208, 176), (252, 224, 168),
|
||||
(248, 216, 120), (216, 248, 120), (184, 248, 184), (184, 248, 216),
|
||||
(0, 252, 252), (216, 216, 216),
|
||||
],
|
||||
"C64": [
|
||||
(0, 0, 0), (255, 255, 255), (161, 77, 67), (106, 191, 199),
|
||||
(161, 87, 164), (92, 172, 95), (64, 64, 223), (191, 206, 137),
|
||||
(161, 104, 60), (108, 80, 21), (203, 126, 117), (98, 98, 98),
|
||||
(137, 137, 137), (154, 226, 155), (124, 124, 255), (173, 173, 173),
|
||||
],
|
||||
"COMMODORE_64": [
|
||||
(0, 0, 0), (255, 255, 255), (161, 77, 67), (106, 192, 200),
|
||||
(161, 87, 165), (92, 172, 95), (64, 68, 227), (203, 214, 137),
|
||||
(163, 104, 58), (110, 84, 11), (204, 127, 118), (99, 99, 99),
|
||||
(139, 139, 139), (154, 227, 157), (139, 127, 205), (175, 175, 175),
|
||||
],
|
||||
"ZX_SPECTRUM": [
|
||||
(0, 0, 0), (0, 39, 251), (252, 48, 22), (255, 63, 252),
|
||||
(0, 249, 44), (0, 252, 254), (255, 253, 51), (255, 255, 255),
|
||||
],
|
||||
"APPLE_II_LO": [
|
||||
(0, 0, 0), (133, 59, 81), (80, 71, 137), (234, 93, 240),
|
||||
(0, 104, 82), (146, 146, 146), (0, 168, 241), (202, 195, 248),
|
||||
(81, 92, 15), (235, 127, 35), (146, 146, 146), (246, 185, 202),
|
||||
(0, 202, 41), (203, 211, 155), (155, 220, 203), (255, 255, 255),
|
||||
],
|
||||
"APPLE_II_HI": [
|
||||
(0, 0, 0), (255, 0, 255), (0, 255, 0), (255, 255, 255),
|
||||
(0, 175, 255), (255, 80, 0),
|
||||
],
|
||||
"GAMEBOY_ORIGINAL": [
|
||||
(0, 63, 0), (46, 115, 32), (140, 191, 10), (160, 207, 10),
|
||||
],
|
||||
"GAMEBOY_POCKET": [
|
||||
(0, 0, 0), (85, 85, 85), (170, 170, 170), (255, 255, 255),
|
||||
],
|
||||
"GAMEBOY_VIRTUALBOY": [
|
||||
(239, 0, 0), (164, 0, 0), (85, 0, 0), (0, 0, 0),
|
||||
],
|
||||
"PICO_8": [
|
||||
(0, 0, 0), (29, 43, 83), (126, 37, 83), (0, 135, 81), (171, 82, 54),
|
||||
(95, 87, 79), (194, 195, 199), (255, 241, 232), (255, 0, 77),
|
||||
(255, 163, 0), (255, 236, 39), (0, 228, 54), (41, 173, 255),
|
||||
(131, 118, 156), (255, 119, 168), (255, 204, 170),
|
||||
],
|
||||
"TELETEXT": [
|
||||
(0, 0, 0), (255, 0, 0), (0, 128, 0), (255, 255, 0),
|
||||
(0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
|
||||
],
|
||||
"CGA_MODE4_PAL1": [
|
||||
(0, 0, 0), (255, 255, 255), (0, 255, 255), (255, 0, 255),
|
||||
],
|
||||
"MSX": [
|
||||
(0, 0, 0), (62, 184, 73), (116, 208, 125), (89, 85, 224),
|
||||
(128, 118, 241), (185, 94, 81), (101, 219, 239), (219, 101, 89),
|
||||
(255, 137, 125), (204, 195, 94), (222, 208, 135), (58, 162, 65),
|
||||
(183, 102, 181), (204, 204, 204), (255, 255, 255),
|
||||
],
|
||||
"MICROSOFT_WINDOWS_16": [
|
||||
(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), (0, 0, 128),
|
||||
(128, 0, 128), (0, 128, 128), (192, 192, 192), (128, 128, 128),
|
||||
(255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255),
|
||||
(255, 0, 255), (0, 255, 255), (255, 255, 255),
|
||||
],
|
||||
"MICROSOFT_WINDOWS_PAINT": [
|
||||
(0, 0, 0), (255, 255, 255), (123, 123, 123), (189, 189, 189),
|
||||
(123, 12, 2), (255, 37, 0), (123, 123, 2), (255, 251, 2),
|
||||
(0, 123, 2), (2, 249, 2), (0, 123, 122), (2, 253, 254),
|
||||
(2, 19, 122), (5, 50, 255), (123, 25, 122), (255, 64, 254),
|
||||
(122, 57, 2), (255, 122, 57), (123, 123, 56), (255, 252, 122),
|
||||
(2, 57, 57), (5, 250, 123), (0, 123, 255), (255, 44, 123),
|
||||
],
|
||||
"MONO_BW": [(0, 0, 0), (255, 255, 255)],
|
||||
"MONO_AMBER": [(40, 40, 40), (255, 176, 0)],
|
||||
"MONO_GREEN": [(40, 40, 40), (51, 255, 51)],
|
||||
|
||||
# ── Artistic palettes ───────────────────────────────────────────────
|
||||
"PASTEL_DREAM": [
|
||||
(255, 218, 233), (255, 229, 204), (255, 255, 204), (204, 255, 229),
|
||||
(204, 229, 255), (229, 204, 255), (255, 204, 229), (204, 255, 255),
|
||||
(255, 245, 220), (230, 230, 250),
|
||||
],
|
||||
"NEON_CYBER": [
|
||||
(0, 0, 0), (255, 0, 128), (0, 255, 255), (255, 0, 255),
|
||||
(0, 255, 128), (255, 255, 0), (128, 0, 255), (255, 128, 0),
|
||||
(0, 128, 255), (255, 255, 255),
|
||||
],
|
||||
"RETRO_WARM": [
|
||||
(62, 39, 35), (139, 69, 19), (210, 105, 30), (244, 164, 96),
|
||||
(255, 218, 185), (255, 245, 238), (178, 34, 34), (205, 92, 92),
|
||||
(255, 99, 71), (255, 160, 122),
|
||||
],
|
||||
"OCEAN_DEEP": [
|
||||
(0, 25, 51), (0, 51, 102), (0, 76, 153), (0, 102, 178),
|
||||
(0, 128, 204), (51, 153, 204), (102, 178, 204), (153, 204, 229),
|
||||
(204, 229, 255), (229, 245, 255),
|
||||
],
|
||||
"FOREST_MOSS": [
|
||||
(34, 51, 34), (51, 76, 51), (68, 102, 51), (85, 128, 68),
|
||||
(102, 153, 85), (136, 170, 102), (170, 196, 136), (204, 221, 170),
|
||||
(238, 238, 204), (245, 245, 220),
|
||||
],
|
||||
"SUNSET_FIRE": [
|
||||
(51, 0, 0), (102, 0, 0), (153, 0, 0), (204, 0, 0), (255, 0, 0),
|
||||
(255, 51, 0), (255, 102, 0), (255, 153, 0), (255, 204, 0),
|
||||
(255, 255, 51),
|
||||
],
|
||||
"ARCTIC_ICE": [
|
||||
(0, 0, 51), (0, 0, 102), (0, 51, 153), (0, 102, 153),
|
||||
(51, 153, 204), (102, 204, 255), (153, 229, 255), (204, 242, 255),
|
||||
(229, 247, 255), (255, 255, 255),
|
||||
],
|
||||
"VINTAGE_ROSE": [
|
||||
(103, 58, 63), (137, 72, 81), (170, 91, 102), (196, 113, 122),
|
||||
(219, 139, 147), (232, 168, 175), (240, 196, 199), (245, 215, 217),
|
||||
(249, 232, 233), (255, 245, 245),
|
||||
],
|
||||
"EARTH_CLAY": [
|
||||
(62, 39, 35), (89, 56, 47), (116, 73, 59), (143, 90, 71),
|
||||
(170, 107, 83), (197, 124, 95), (210, 155, 126), (222, 186, 160),
|
||||
(235, 217, 196), (248, 248, 232),
|
||||
],
|
||||
"ELECTRIC_VIOLET": [
|
||||
(26, 0, 51), (51, 0, 102), (76, 0, 153), (102, 0, 204),
|
||||
(128, 0, 255), (153, 51, 255), (178, 102, 255), (204, 153, 255),
|
||||
(229, 204, 255), (245, 229, 255),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_palette_image(palette_name):
|
||||
"""Build a 1x1 PIL 'P'-mode image with the named palette for Image.quantize(palette=...)."""
|
||||
from PIL import Image
|
||||
|
||||
if palette_name not in PALETTES:
|
||||
raise ValueError(
|
||||
f"Unknown palette {palette_name!r}. "
|
||||
f"Choose from: {sorted(PALETTES)}"
|
||||
)
|
||||
flat = []
|
||||
for (r, g, b) in PALETTES[palette_name]:
|
||||
flat.extend([r, g, b])
|
||||
# Pad to 768 bytes (256 colors) as PIL requires
|
||||
while len(flat) < 768:
|
||||
flat.append(0)
|
||||
pal_img = Image.new("P", (1, 1))
|
||||
pal_img.putpalette(flat)
|
||||
return pal_img
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Pixel art converter — Floyd-Steinberg dithering with preset or named palette.
|
||||
|
||||
Named hardware palettes (NES, GameBoy, PICO-8, C64, etc.) ported from
|
||||
pixel-art-studio (MIT) — see ATTRIBUTION.md.
|
||||
|
||||
Usage (import):
|
||||
from pixel_art import pixel_art
|
||||
pixel_art("in.png", "out.png", preset="arcade")
|
||||
pixel_art("in.png", "out.png", preset="nes")
|
||||
pixel_art("in.png", "out.png", palette="PICO_8", block=6)
|
||||
|
||||
Usage (CLI):
|
||||
python pixel_art.py in.png out.png --preset nes
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageEnhance, ImageOps
|
||||
|
||||
try:
|
||||
from .palettes import PALETTES, build_palette_image
|
||||
except ImportError:
|
||||
from palettes import PALETTES, build_palette_image
|
||||
|
||||
|
||||
PRESETS = {
|
||||
# ── Original presets (adaptive palette) ─────────────────────────────
|
||||
"arcade": {
|
||||
"contrast": 1.8, "color": 1.5, "sharpness": 1.2,
|
||||
"posterize_bits": 5, "block": 8, "palette": 16,
|
||||
},
|
||||
"snes": {
|
||||
"contrast": 1.6, "color": 1.4, "sharpness": 1.2,
|
||||
"posterize_bits": 6, "block": 4, "palette": 32,
|
||||
},
|
||||
# ── Hardware-accurate presets (named palette) ───────────────────────
|
||||
"nes": {
|
||||
"contrast": 1.5, "color": 1.4, "sharpness": 1.2,
|
||||
"posterize_bits": 6, "block": 8, "palette": "NES",
|
||||
},
|
||||
"gameboy": {
|
||||
"contrast": 1.5, "color": 1.0, "sharpness": 1.2,
|
||||
"posterize_bits": 6, "block": 8, "palette": "GAMEBOY_ORIGINAL",
|
||||
},
|
||||
"gameboy_pocket": {
|
||||
"contrast": 1.5, "color": 1.0, "sharpness": 1.2,
|
||||
"posterize_bits": 6, "block": 8, "palette": "GAMEBOY_POCKET",
|
||||
},
|
||||
"pico8": {
|
||||
"contrast": 1.6, "color": 1.3, "sharpness": 1.2,
|
||||
"posterize_bits": 6, "block": 6, "palette": "PICO_8",
|
||||
},
|
||||
"c64": {
|
||||
"contrast": 1.6, "color": 1.3, "sharpness": 1.2,
|
||||
"posterize_bits": 6, "block": 8, "palette": "C64",
|
||||
},
|
||||
"apple2": {
|
||||
"contrast": 1.8, "color": 1.4, "sharpness": 1.2,
|
||||
"posterize_bits": 5, "block": 10, "palette": "APPLE_II_HI",
|
||||
},
|
||||
"teletext": {
|
||||
"contrast": 1.8, "color": 1.5, "sharpness": 1.2,
|
||||
"posterize_bits": 5, "block": 10, "palette": "TELETEXT",
|
||||
},
|
||||
"mspaint": {
|
||||
"contrast": 1.6, "color": 1.4, "sharpness": 1.2,
|
||||
"posterize_bits": 6, "block": 8, "palette": "MICROSOFT_WINDOWS_PAINT",
|
||||
},
|
||||
"mono_green": {
|
||||
"contrast": 1.8, "color": 0.0, "sharpness": 1.2,
|
||||
"posterize_bits": 5, "block": 6, "palette": "MONO_GREEN",
|
||||
},
|
||||
"mono_amber": {
|
||||
"contrast": 1.8, "color": 0.0, "sharpness": 1.2,
|
||||
"posterize_bits": 5, "block": 6, "palette": "MONO_AMBER",
|
||||
},
|
||||
# ── Artistic palette presets ────────────────────────────────────────
|
||||
"neon": {
|
||||
"contrast": 1.8, "color": 1.6, "sharpness": 1.2,
|
||||
"posterize_bits": 5, "block": 6, "palette": "NEON_CYBER",
|
||||
},
|
||||
"pastel": {
|
||||
"contrast": 1.2, "color": 1.3, "sharpness": 1.1,
|
||||
"posterize_bits": 6, "block": 6, "palette": "PASTEL_DREAM",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def pixel_art(input_path, output_path, preset="arcade", **overrides):
|
||||
"""Convert an image to retro pixel art.
|
||||
|
||||
Args:
|
||||
input_path: path to source image
|
||||
output_path: path to save the resulting PNG
|
||||
preset: one of PRESETS (arcade, snes, nes, gameboy, pico8, c64, ...)
|
||||
**overrides: optionally override any preset field. In particular:
|
||||
palette: int (adaptive N colors) OR str (named palette from PALETTES)
|
||||
block: int pixel block size
|
||||
contrast / color / sharpness / posterize_bits: numeric enhancers
|
||||
|
||||
Returns:
|
||||
The resulting PIL.Image.
|
||||
"""
|
||||
if preset not in PRESETS:
|
||||
raise ValueError(
|
||||
f"Unknown preset {preset!r}. Choose from: {sorted(PRESETS)}"
|
||||
)
|
||||
cfg = {**PRESETS[preset], **overrides}
|
||||
|
||||
img = Image.open(input_path).convert("RGB")
|
||||
|
||||
img = ImageEnhance.Contrast(img).enhance(cfg["contrast"])
|
||||
img = ImageEnhance.Color(img).enhance(cfg["color"])
|
||||
img = ImageEnhance.Sharpness(img).enhance(cfg["sharpness"])
|
||||
img = ImageOps.posterize(img, cfg["posterize_bits"])
|
||||
|
||||
w, h = img.size
|
||||
block = cfg["block"]
|
||||
small = img.resize(
|
||||
(max(1, w // block), max(1, h // block)),
|
||||
Image.NEAREST,
|
||||
)
|
||||
|
||||
# Quantize AFTER downscale so Floyd-Steinberg aligns with final pixel grid.
|
||||
pal = cfg["palette"]
|
||||
if isinstance(pal, str):
|
||||
# Named hardware/artistic palette
|
||||
pal_img = build_palette_image(pal)
|
||||
quantized = small.quantize(palette=pal_img, dither=Image.FLOYDSTEINBERG)
|
||||
else:
|
||||
# Adaptive N-color palette (original behavior)
|
||||
quantized = small.quantize(colors=int(pal), dither=Image.FLOYDSTEINBERG)
|
||||
|
||||
result = quantized.resize((w, h), Image.NEAREST)
|
||||
result.save(output_path, "PNG")
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="Convert image to pixel art.")
|
||||
p.add_argument("input")
|
||||
p.add_argument("output")
|
||||
p.add_argument("--preset", default="arcade", choices=sorted(PRESETS))
|
||||
p.add_argument("--palette", default=None,
|
||||
help=f"Override palette: int or name from {sorted(PALETTES)}")
|
||||
p.add_argument("--block", type=int, default=None)
|
||||
args = p.parse_args()
|
||||
|
||||
overrides = {}
|
||||
if args.palette is not None:
|
||||
try:
|
||||
overrides["palette"] = int(args.palette)
|
||||
except ValueError:
|
||||
overrides["palette"] = args.palette
|
||||
if args.block is not None:
|
||||
overrides["block"] = args.block
|
||||
|
||||
pixel_art(args.input, args.output, preset=args.preset, **overrides)
|
||||
print(f"Wrote {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Pixel art video — overlay procedural animations onto a source image.
|
||||
|
||||
Takes any image (typically pre-processed with pixel_art()) and overlays
|
||||
animated pixel effects (stars, rain, fireflies, etc.), then encodes to MP4
|
||||
(and optionally GIF) via ffmpeg.
|
||||
|
||||
Scene animations ported from pixel-art-studio (MIT) — see ATTRIBUTION.md.
|
||||
The generative/Pollinations code is intentionally dropped — Hermes uses
|
||||
`image_generate` + `pixel_art()` for base frames instead.
|
||||
|
||||
Usage (import):
|
||||
from pixel_art_video import pixel_art_video
|
||||
pixel_art_video("frame.png", "out.mp4", scene="night", duration=6)
|
||||
|
||||
Usage (CLI):
|
||||
python pixel_art_video.py frame.png out.mp4 --scene night --duration 6 --gif
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
# ── Pixel drawing helpers ──────────────────────────────────────────────
|
||||
|
||||
def _px(draw, x, y, color, size=2):
|
||||
x, y = int(x), int(y)
|
||||
W, H = draw.im.size
|
||||
if 0 <= x < W and 0 <= y < H:
|
||||
draw.rectangle([x, y, x + size - 1, y + size - 1], fill=color)
|
||||
|
||||
|
||||
def _pixel_cross(draw, x, y, color, arm=2):
|
||||
x, y = int(x), int(y)
|
||||
for i in range(-arm, arm + 1):
|
||||
_px(draw, x + i, y, color, 1)
|
||||
_px(draw, x, y + i, color, 1)
|
||||
|
||||
|
||||
# ── Animation init/draw pairs ──────────────────────────────────────────
|
||||
|
||||
def init_stars(rng, W, H):
|
||||
return [(rng.randint(0, W), rng.randint(0, H // 2)) for _ in range(15)]
|
||||
|
||||
def draw_stars(draw, stars, t, W, H):
|
||||
for i, (sx, sy) in enumerate(stars):
|
||||
if math.sin(t * 2.0 + i * 0.7) > 0.65:
|
||||
_pixel_cross(draw, sx, sy, (255, 255, 220), arm=2)
|
||||
|
||||
|
||||
def init_fireflies(rng, W, H):
|
||||
return [{"x": rng.randint(20, W - 20), "y": rng.randint(H // 4, H - 20),
|
||||
"phase": rng.uniform(0, 6.28), "speed": rng.uniform(0.3, 0.8)}
|
||||
for _ in range(10)]
|
||||
|
||||
def draw_fireflies(draw, ff, t, W, H):
|
||||
for f in ff:
|
||||
if math.sin(t * 1.5 + f["phase"]) < 0.15:
|
||||
continue
|
||||
_px(draw,
|
||||
f["x"] + math.sin(t * f["speed"] + f["phase"]) * 3,
|
||||
f["y"] + math.cos(t * f["speed"] * 0.7) * 2,
|
||||
(200, 255, 100), 2)
|
||||
|
||||
|
||||
def init_leaves(rng, W, H):
|
||||
return [{"x": rng.randint(0, W), "y": rng.randint(-H, 0),
|
||||
"speed": rng.uniform(0.5, 1.5), "wobble": rng.uniform(0.02, 0.05),
|
||||
"phase": rng.uniform(0, 6.28),
|
||||
"color": rng.choice([(180, 120, 50), (160, 100, 40), (200, 140, 60)])}
|
||||
for _ in range(12)]
|
||||
|
||||
def draw_leaves(draw, leaves, t, W, H):
|
||||
for leaf in leaves:
|
||||
_px(draw,
|
||||
leaf["x"] + math.sin(t * leaf["wobble"] + leaf["phase"]) * 15,
|
||||
(leaf["y"] + t * leaf["speed"] * 20) % (H + 40) - 20,
|
||||
leaf["color"], 2)
|
||||
|
||||
|
||||
def init_dust_motes(rng, W, H):
|
||||
return [{"x": rng.randint(30, W - 30), "y": rng.randint(30, H - 30),
|
||||
"phase": rng.uniform(0, 6.28), "speed": rng.uniform(0.2, 0.5),
|
||||
"amp": rng.uniform(2, 6)} for _ in range(20)]
|
||||
|
||||
def draw_dust_motes(draw, motes, t, W, H):
|
||||
for m in motes:
|
||||
if math.sin(t * 2.0 + m["phase"]) > 0.3:
|
||||
_px(draw,
|
||||
m["x"] + math.sin(t * 0.3 + m["phase"]) * m["amp"],
|
||||
m["y"] - (m["speed"] * t * 15) % H,
|
||||
(255, 210, 100), 1)
|
||||
|
||||
|
||||
def init_sparkles(rng, W, H):
|
||||
return [(rng.randint(W // 4, 3 * W // 4), rng.randint(H // 4, 3 * H // 4),
|
||||
rng.uniform(0, 6.28),
|
||||
rng.choice([(180, 200, 255), (255, 220, 150), (200, 180, 255)]))
|
||||
for _ in range(10)]
|
||||
|
||||
def draw_sparkles(draw, sparkles, t, W, H):
|
||||
for sx, sy, phase, color in sparkles:
|
||||
if math.sin(t * 1.8 + phase) > 0.6:
|
||||
_pixel_cross(draw, sx, sy, color, arm=2)
|
||||
|
||||
|
||||
def init_rain(rng, W, H):
|
||||
return [{"x": rng.randint(0, W), "y": rng.randint(0, H),
|
||||
"speed": rng.uniform(4, 8)} for _ in range(30)]
|
||||
|
||||
def draw_rain(draw, rain, t, W, H):
|
||||
for r in rain:
|
||||
y = (r["y"] + t * r["speed"] * 20) % H
|
||||
_px(draw, r["x"], y, (120, 150, 200), 1)
|
||||
_px(draw, r["x"], y + 4, (100, 130, 180), 1)
|
||||
|
||||
|
||||
def init_lightning(rng, W, H):
|
||||
return {"timer": 0, "flash": False, "rng": rng}
|
||||
|
||||
def draw_lightning(draw, state, t, W, H):
|
||||
state["timer"] += 1
|
||||
if state["timer"] > 45 and state["rng"].random() < 0.04:
|
||||
state["flash"] = True
|
||||
state["timer"] = 0
|
||||
if state["flash"]:
|
||||
for x in range(0, W, 4):
|
||||
for y in range(0, H // 3, 3):
|
||||
if state["rng"].random() < 0.12:
|
||||
_px(draw, x, y, (255, 255, 240), 2)
|
||||
state["flash"] = False
|
||||
|
||||
|
||||
def init_bubbles(rng, W, H):
|
||||
return [{"x": rng.randint(20, W - 20), "y": rng.randint(H, H * 2),
|
||||
"speed": rng.uniform(0.3, 0.8), "size": rng.choice([1, 2, 2])}
|
||||
for _ in range(15)]
|
||||
|
||||
def draw_bubbles(draw, bubbles, t, W, H):
|
||||
for b in bubbles:
|
||||
x = b["x"] + math.sin(t * 0.5 + b["x"]) * 3
|
||||
y = b["y"] - (t * b["speed"] * 20) % (H + 40)
|
||||
if 0 < y < H:
|
||||
_px(draw, x, y, (150, 200, 255), b["size"])
|
||||
|
||||
|
||||
def init_embers(rng, W, H):
|
||||
return [{"x": rng.randint(0, W), "y": rng.randint(0, H),
|
||||
"speed": rng.uniform(0.3, 0.9), "phase": rng.uniform(0, 6.28),
|
||||
"color": rng.choice([(255, 150, 30), (255, 100, 20), (255, 200, 50)])}
|
||||
for _ in range(18)]
|
||||
|
||||
def draw_embers(draw, embers, t, W, H):
|
||||
for e in embers:
|
||||
x = e["x"] + math.sin(t * 0.4 + e["phase"]) * 5
|
||||
y = e["y"] - (t * e["speed"] * 15) % H
|
||||
if math.sin(t * 2.5 + e["phase"]) > 0.2:
|
||||
_px(draw, x, y, e["color"], 2)
|
||||
|
||||
|
||||
def init_snowflakes(rng, W, H):
|
||||
return [{"x": rng.randint(0, W), "y": rng.randint(-H, 0),
|
||||
"speed": rng.uniform(0.3, 0.6), "wobble": rng.uniform(0.04, 0.09),
|
||||
"size": rng.choice([2, 2, 3])}
|
||||
for _ in range(40)]
|
||||
|
||||
def draw_snowflakes(draw, flakes, t, W, H):
|
||||
for f in flakes:
|
||||
x = f["x"] + math.sin(t * f["wobble"] + f["x"]) * 20
|
||||
y = (f["y"] + t * f["speed"] * 8) % (H + 20) - 10
|
||||
if f["size"] >= 3:
|
||||
_pixel_cross(draw, x, y, (230, 235, 255), arm=1)
|
||||
else:
|
||||
_px(draw, x, y, (230, 235, 255), 2)
|
||||
|
||||
|
||||
def init_neon_pulse(rng, W, H):
|
||||
return [(rng.randint(0, W), rng.randint(0, H), rng.uniform(0, 6.28),
|
||||
rng.choice([(255, 0, 200), (0, 255, 255), (255, 50, 150)]))
|
||||
for _ in range(8)]
|
||||
|
||||
def draw_neon_pulse(draw, points, t, W, H):
|
||||
for x, y, phase, color in points:
|
||||
if math.sin(t * 2.5 + phase) > 0.5:
|
||||
_pixel_cross(draw, x, y, color, arm=3)
|
||||
|
||||
|
||||
def init_heat_shimmer(rng, W, H):
|
||||
return [{"x": rng.randint(0, W), "y": rng.randint(H // 2, H),
|
||||
"phase": rng.uniform(0, 6.28)} for _ in range(12)]
|
||||
|
||||
def draw_heat_shimmer(draw, points, t, W, H):
|
||||
for p in points:
|
||||
x = p["x"] + math.sin(t * 0.8 + p["phase"]) * 2
|
||||
y = p["y"] + math.sin(t * 1.2 + p["phase"]) * 1
|
||||
if abs(math.sin(t * 1.5 + p["phase"])) > 0.6:
|
||||
_px(draw, x, y, (255, 200, 100), 1)
|
||||
|
||||
|
||||
# ── Scene → animation mapping ──────────────────────────────────────────
|
||||
|
||||
SCENES = {
|
||||
"night": ["stars", "fireflies", "leaves"],
|
||||
"dusk": ["fireflies", "sparkles"],
|
||||
"tavern": ["dust_motes", "sparkles"],
|
||||
"indoor": ["dust_motes"],
|
||||
"urban": ["rain", "neon_pulse"],
|
||||
"nature": ["leaves", "fireflies"],
|
||||
"magic": ["sparkles", "fireflies"],
|
||||
"storm": ["rain", "lightning"],
|
||||
"underwater": ["bubbles", "sparkles"],
|
||||
"fire": ["embers", "sparkles"],
|
||||
"snow": ["snowflakes", "sparkles"],
|
||||
"desert": ["heat_shimmer", "dust_motes"],
|
||||
}
|
||||
|
||||
# Map scene layer name to (init_fn, draw_fn).
|
||||
_LAYERS = {
|
||||
"stars": (init_stars, draw_stars),
|
||||
"fireflies": (init_fireflies, draw_fireflies),
|
||||
"leaves": (init_leaves, draw_leaves),
|
||||
"dust_motes": (init_dust_motes, draw_dust_motes),
|
||||
"sparkles": (init_sparkles, draw_sparkles),
|
||||
"rain": (init_rain, draw_rain),
|
||||
"lightning": (init_lightning, draw_lightning),
|
||||
"bubbles": (init_bubbles, draw_bubbles),
|
||||
"embers": (init_embers, draw_embers),
|
||||
"snowflakes": (init_snowflakes, draw_snowflakes),
|
||||
"neon_pulse": (init_neon_pulse, draw_neon_pulse),
|
||||
"heat_shimmer": (init_heat_shimmer, draw_heat_shimmer),
|
||||
}
|
||||
|
||||
|
||||
def _ensure_ffmpeg():
|
||||
if shutil.which("ffmpeg") is None:
|
||||
raise RuntimeError(
|
||||
"ffmpeg not found on PATH. Install via your package manager or "
|
||||
"download from https://ffmpeg.org/"
|
||||
)
|
||||
|
||||
|
||||
def pixel_art_video(
|
||||
base_image,
|
||||
output_path,
|
||||
scene="night",
|
||||
duration=6,
|
||||
fps=15,
|
||||
seed=None,
|
||||
export_gif=False,
|
||||
):
|
||||
"""Overlay pixel animations onto a base image and encode to MP4.
|
||||
|
||||
Args:
|
||||
base_image: path to source image (ideally already pixel-art styled)
|
||||
output_path: path to MP4 output (GIF sibling written if export_gif=True)
|
||||
scene: key from SCENES (night, urban, storm, snow, fire, ...)
|
||||
duration: seconds of animation
|
||||
fps: frames per second (default 15 for retro feel)
|
||||
seed: optional int for reproducible animation placement
|
||||
export_gif: also write a GIF alongside the MP4
|
||||
|
||||
Returns:
|
||||
(mp4_path, gif_path_or_None)
|
||||
"""
|
||||
if scene not in SCENES:
|
||||
raise ValueError(
|
||||
f"Unknown scene {scene!r}. Choose from: {sorted(SCENES)}"
|
||||
)
|
||||
_ensure_ffmpeg()
|
||||
|
||||
base = Image.open(base_image).convert("RGB")
|
||||
W, H = base.size
|
||||
|
||||
rng = random.Random(seed if seed is not None else 42)
|
||||
layers = []
|
||||
for name in SCENES[scene]:
|
||||
init_fn, draw_fn = _LAYERS[name]
|
||||
layers.append((draw_fn, init_fn(rng, W, H)))
|
||||
|
||||
n_frames = fps * duration
|
||||
os.makedirs(os.path.dirname(os.path.abspath(output_path)) or ".", exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="pixelart_frames_") as frames_dir:
|
||||
for frame_idx in range(n_frames):
|
||||
canvas = base.copy()
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
t = frame_idx / fps
|
||||
for draw_fn, state in layers:
|
||||
draw_fn(draw, state, t, W, H)
|
||||
canvas.save(os.path.join(frames_dir, f"frame_{frame_idx:04d}.png"))
|
||||
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-loglevel", "error",
|
||||
"-framerate", str(fps),
|
||||
"-i", os.path.join(frames_dir, "frame_%04d.png"),
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18",
|
||||
output_path],
|
||||
check=True,
|
||||
)
|
||||
|
||||
gif_path = None
|
||||
if export_gif:
|
||||
gif_path = output_path.rsplit(".", 1)[0] + ".gif"
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-loglevel", "error",
|
||||
"-framerate", str(fps),
|
||||
"-i", os.path.join(frames_dir, "frame_%04d.png"),
|
||||
"-vf",
|
||||
"scale=320:-1:flags=neighbor,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse",
|
||||
"-loop", "0",
|
||||
gif_path],
|
||||
check=True,
|
||||
)
|
||||
|
||||
return output_path, gif_path
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="Overlay pixel animations onto an image → MP4.")
|
||||
p.add_argument("base_image")
|
||||
p.add_argument("output")
|
||||
p.add_argument("--scene", default="night", choices=sorted(SCENES))
|
||||
p.add_argument("--duration", type=int, default=6)
|
||||
p.add_argument("--fps", type=int, default=15)
|
||||
p.add_argument("--seed", type=int, default=None)
|
||||
p.add_argument("--gif", action="store_true")
|
||||
args = p.parse_args()
|
||||
mp4, gif = pixel_art_video(
|
||||
args.base_image, args.output,
|
||||
scene=args.scene, duration=args.duration,
|
||||
fps=args.fps, seed=args.seed, export_gif=args.gif,
|
||||
)
|
||||
print(f"Wrote {mp4}")
|
||||
if gif:
|
||||
print(f"Wrote {gif}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Tests for acp_adapter.entry._BenignProbeMethodFilter.
|
||||
|
||||
Covers both the isolated filter logic and the full end-to-end path where a
|
||||
client sends a bare JSON-RPC ``ping`` request over stdio and the acp runtime
|
||||
surfaces the resulting ``RequestError`` via ``logging.exception("Background
|
||||
task failed", ...)``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
|
||||
from acp.exceptions import RequestError
|
||||
|
||||
from acp_adapter.entry import _BenignProbeMethodFilter
|
||||
|
||||
|
||||
# -- Unit tests on the filter itself ----------------------------------------
|
||||
|
||||
|
||||
def _make_record(msg: str, exc: BaseException | None) -> logging.LogRecord:
|
||||
record = logging.LogRecord(
|
||||
name="root",
|
||||
level=logging.ERROR,
|
||||
pathname=__file__,
|
||||
lineno=0,
|
||||
msg=msg,
|
||||
args=(),
|
||||
exc_info=(type(exc), exc, exc.__traceback__) if exc else None,
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def _bake_tb(exc: BaseException) -> BaseException:
|
||||
try:
|
||||
raise exc
|
||||
except BaseException as e: # noqa: BLE001
|
||||
return e
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["ping", "health", "healthcheck"])
|
||||
def test_filter_suppresses_benign_probe(method: str) -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RequestError.method_not_found(method))
|
||||
record = _make_record("Background task failed", exc)
|
||||
assert f.filter(record) is False
|
||||
|
||||
|
||||
def test_filter_allows_real_method_not_found() -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RequestError.method_not_found("session/custom"))
|
||||
record = _make_record("Background task failed", exc)
|
||||
assert f.filter(record) is True
|
||||
|
||||
|
||||
def test_filter_allows_non_request_error() -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RuntimeError("boom"))
|
||||
record = _make_record("Background task failed", exc)
|
||||
assert f.filter(record) is True
|
||||
|
||||
|
||||
def test_filter_allows_different_message_even_for_ping() -> None:
|
||||
"""Only 'Background task failed' is muted — other messages pass through."""
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RequestError.method_not_found("ping"))
|
||||
record = _make_record("Some other context", exc)
|
||||
assert f.filter(record) is True
|
||||
|
||||
|
||||
def test_filter_allows_request_error_with_different_code() -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RequestError.invalid_params({"method": "ping"}))
|
||||
record = _make_record("Background task failed", exc)
|
||||
assert f.filter(record) is True
|
||||
|
||||
|
||||
def test_filter_allows_log_without_exc_info() -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
record = _make_record("Background task failed", None)
|
||||
assert f.filter(record) is True
|
||||
|
||||
|
||||
# -- End-to-end: drive a real JSON-RPC `ping` through acp.run_agent ---------
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
"""Minimal acp.Agent stub — we only need the router to build."""
|
||||
|
||||
async def initialize(self, **kwargs): # noqa: ANN003
|
||||
from acp.schema import AgentCapabilities, InitializeResponse
|
||||
|
||||
return InitializeResponse(protocol_version=1, agent_capabilities=AgentCapabilities())
|
||||
|
||||
async def new_session(self, cwd, mcp_servers=None, **kwargs): # noqa: ANN001, ANN003
|
||||
from acp.schema import NewSessionResponse
|
||||
|
||||
return NewSessionResponse(session_id="test")
|
||||
|
||||
async def prompt(self, session_id, prompt, **kwargs): # noqa: ANN001, ANN003
|
||||
from acp.schema import PromptResponse
|
||||
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
async def cancel(self, session_id, **kwargs): # noqa: ANN001, ANN003
|
||||
pass
|
||||
|
||||
async def authenticate(self, **kwargs): # noqa: ANN003
|
||||
pass
|
||||
|
||||
def on_connect(self, conn): # noqa: ANN001
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_ping_request_produces_proper_response_and_no_stderr_noise(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A bare ``ping`` must get a JSON-RPC -32601 back AND leave stderr clean
|
||||
when the filter is installed on the handler.
|
||||
"""
|
||||
import acp
|
||||
|
||||
# Attach the filter to a fresh stream handler that mirrors entry._setup_logging.
|
||||
stream = StringIO()
|
||||
handler = logging.StreamHandler(stream)
|
||||
handler.setFormatter(logging.Formatter("%(name)s|%(levelname)s|%(message)s"))
|
||||
handler.addFilter(_BenignProbeMethodFilter())
|
||||
root = logging.getLogger()
|
||||
prior_handlers = root.handlers[:]
|
||||
prior_level = root.level
|
||||
root.handlers = [handler]
|
||||
root.setLevel(logging.INFO)
|
||||
# Also suppress propagation of caplog's default handler interfering with
|
||||
# our stream (caplog still captures via its own propagation hook).
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Pipe client -> agent
|
||||
client_to_agent_r, client_to_agent_w = os.pipe()
|
||||
# Pipe agent -> client
|
||||
agent_to_client_r, agent_to_client_w = os.pipe()
|
||||
|
||||
in_read_file = os.fdopen(client_to_agent_r, "rb", buffering=0)
|
||||
in_write_file = os.fdopen(client_to_agent_w, "wb", buffering=0)
|
||||
out_read_file = os.fdopen(agent_to_client_r, "rb", buffering=0)
|
||||
out_write_file = os.fdopen(agent_to_client_w, "wb", buffering=0)
|
||||
|
||||
# Agent reads its input from this StreamReader:
|
||||
agent_input = asyncio.StreamReader(limit=1024 * 1024, loop=loop)
|
||||
agent_input_proto = asyncio.StreamReaderProtocol(agent_input, loop=loop)
|
||||
await loop.connect_read_pipe(lambda: agent_input_proto, in_read_file)
|
||||
|
||||
# Agent writes its output via this StreamWriter:
|
||||
out_transport, out_protocol = await loop.connect_write_pipe(
|
||||
asyncio.streams.FlowControlMixin, out_write_file
|
||||
)
|
||||
agent_output = asyncio.StreamWriter(out_transport, out_protocol, None, loop)
|
||||
|
||||
# Test harness reads agent output via this StreamReader:
|
||||
client_input = asyncio.StreamReader(limit=1024 * 1024, loop=loop)
|
||||
client_input_proto = asyncio.StreamReaderProtocol(client_input, loop=loop)
|
||||
await loop.connect_read_pipe(lambda: client_input_proto, out_read_file)
|
||||
|
||||
agent_task = asyncio.create_task(
|
||||
acp.run_agent(
|
||||
_FakeAgent(),
|
||||
input_stream=agent_output,
|
||||
output_stream=agent_input,
|
||||
use_unstable_protocol=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Send a bare `ping`
|
||||
request = {"jsonrpc": "2.0", "id": 1, "method": "ping", "params": {}}
|
||||
in_write_file.write((json.dumps(request) + "\n").encode())
|
||||
in_write_file.flush()
|
||||
|
||||
response_line = await asyncio.wait_for(client_input.readline(), timeout=5.0)
|
||||
# Give the supervisor task a tick to fire (filter should eat it)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
response = json.loads(response_line.decode())
|
||||
assert response["error"]["code"] == -32601, response
|
||||
assert response["error"]["data"] == {"method": "ping"}, response
|
||||
|
||||
logs = stream.getvalue()
|
||||
assert "Background task failed" not in logs, (
|
||||
f"ping noise leaked to stderr:\n{logs}"
|
||||
)
|
||||
|
||||
# Clean shutdown
|
||||
in_write_file.close()
|
||||
try:
|
||||
await asyncio.wait_for(agent_task, timeout=2.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
agent_task.cancel()
|
||||
try:
|
||||
await agent_task
|
||||
except BaseException: # noqa: BLE001
|
||||
pass
|
||||
finally:
|
||||
root.handlers = prior_handlers
|
||||
root.setLevel(prior_level)
|
||||
@@ -832,6 +832,94 @@ class TestKimiForCodingTemperature:
|
||||
|
||||
assert kwargs["temperature"] == 0.3
|
||||
|
||||
# ── Endpoint-aware overrides: api.moonshot.ai vs api.kimi.com/coding ──
|
||||
# The public Moonshot chat endpoint and the Coding Plan endpoint enforce
|
||||
# different temperature contracts for the same model name. `kimi-k2.5` on
|
||||
# api.moonshot.ai rejects 0.6 with HTTP 400 "only 1 is allowed for this
|
||||
# model", while the Coding Plan docs mandate 0.6. Override must pick the
|
||||
# right value per base_url.
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base_url",
|
||||
[
|
||||
"https://api.moonshot.ai/v1",
|
||||
"https://api.moonshot.ai/v1/",
|
||||
"https://API.MOONSHOT.AI/v1",
|
||||
"https://api.moonshot.cn/v1",
|
||||
"https://api.moonshot.cn/v1/",
|
||||
],
|
||||
)
|
||||
def test_kimi_k2_5_public_api_forces_temperature_1(self, base_url):
|
||||
"""kimi-k2.5 on the public Moonshot API only accepts temperature=1."""
|
||||
from agent.auxiliary_client import _build_call_kwargs
|
||||
|
||||
kwargs = _build_call_kwargs(
|
||||
provider="kimi-coding",
|
||||
model="kimi-k2.5",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
temperature=0.1,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
assert kwargs["temperature"] == 1.0
|
||||
|
||||
def test_kimi_k2_5_coding_plan_keeps_temperature_0_6(self):
|
||||
"""kimi-k2.5 on api.kimi.com/coding keeps the Coding Plan's 0.6 lock."""
|
||||
from agent.auxiliary_client import _build_call_kwargs
|
||||
|
||||
kwargs = _build_call_kwargs(
|
||||
provider="kimi-coding",
|
||||
model="kimi-k2.5",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
temperature=0.1,
|
||||
base_url="https://api.kimi.com/coding/v1",
|
||||
)
|
||||
|
||||
assert kwargs["temperature"] == 0.6
|
||||
|
||||
def test_kimi_k2_5_no_base_url_falls_back_to_coding_plan_lock(self):
|
||||
"""Without a base_url hint, the Coding Plan default (0.6) applies.
|
||||
|
||||
Preserves PR #12144 backward compatibility for callers that don't thread
|
||||
the client's base_url through.
|
||||
"""
|
||||
from agent.auxiliary_client import _build_call_kwargs
|
||||
|
||||
kwargs = _build_call_kwargs(
|
||||
provider="kimi-coding",
|
||||
model="kimi-k2.5",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
temperature=0.1,
|
||||
)
|
||||
|
||||
assert kwargs["temperature"] == 0.6
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model,expected",
|
||||
[
|
||||
# Only kimi-k2.5 diverges on api.moonshot.ai; the rest keep the
|
||||
# Coding Plan lock (empirically verified against Moonshot in April
|
||||
# 2026: turbo-preview accepts 0.6, thinking-turbo accepts 1.0).
|
||||
("kimi-k2-turbo-preview", 0.6),
|
||||
("kimi-k2-0905-preview", 0.6),
|
||||
("kimi-k2-thinking", 1.0),
|
||||
("kimi-k2-thinking-turbo", 1.0),
|
||||
("moonshotai/kimi-k2-thinking-turbo", 1.0),
|
||||
],
|
||||
)
|
||||
def test_other_kimi_k2_family_unchanged_on_public_api(self, model, expected):
|
||||
from agent.auxiliary_client import _build_call_kwargs
|
||||
|
||||
kwargs = _build_call_kwargs(
|
||||
provider="kimi-coding",
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
temperature=0.1,
|
||||
base_url="https://api.moonshot.ai/v1",
|
||||
)
|
||||
|
||||
assert kwargs["temperature"] == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# async_call_llm payment / connection fallback (#7512 bug 2)
|
||||
@@ -858,6 +946,70 @@ class TestStaleBaseUrlWarning:
|
||||
"Expected a warning about stale OPENAI_BASE_URL"
|
||||
assert mod._stale_base_url_warned is True
|
||||
|
||||
|
||||
class TestAuxiliaryTaskExtraBody:
|
||||
def test_sync_call_merges_task_extra_body_from_config(self):
|
||||
client = MagicMock()
|
||||
client.base_url = "https://api.example.com/v1"
|
||||
response = MagicMock()
|
||||
client.chat.completions.create.return_value = response
|
||||
|
||||
config = {
|
||||
"auxiliary": {
|
||||
"session_search": {
|
||||
"extra_body": {
|
||||
"enable_thinking": False,
|
||||
"reasoning": {"effort": "none"},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with patch("hermes_cli.config.load_config", return_value=config), patch(
|
||||
"agent.auxiliary_client._get_cached_client",
|
||||
return_value=(client, "glm-4.5-air"),
|
||||
):
|
||||
result = call_llm(
|
||||
task="session_search",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
extra_body={"metadata": {"source": "test"}},
|
||||
)
|
||||
|
||||
assert result is response
|
||||
kwargs = client.chat.completions.create.call_args.kwargs
|
||||
assert kwargs["extra_body"]["enable_thinking"] is False
|
||||
assert kwargs["extra_body"]["reasoning"] == {"effort": "none"}
|
||||
assert kwargs["extra_body"]["metadata"] == {"source": "test"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_call_explicit_extra_body_overrides_task_config(self):
|
||||
client = MagicMock()
|
||||
client.base_url = "https://api.example.com/v1"
|
||||
response = MagicMock()
|
||||
client.chat.completions.create = AsyncMock(return_value=response)
|
||||
|
||||
config = {
|
||||
"auxiliary": {
|
||||
"session_search": {
|
||||
"extra_body": {"enable_thinking": False}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with patch("hermes_cli.config.load_config", return_value=config), patch(
|
||||
"agent.auxiliary_client._get_cached_client",
|
||||
return_value=(client, "glm-4.5-air"),
|
||||
):
|
||||
result = await async_call_llm(
|
||||
task="session_search",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
extra_body={"enable_thinking": True},
|
||||
)
|
||||
|
||||
assert result is response
|
||||
kwargs = client.chat.completions.create.call_args.kwargs
|
||||
assert kwargs["extra_body"]["enable_thinking"] is True
|
||||
|
||||
def test_no_warning_when_provider_is_custom(self, monkeypatch, caplog):
|
||||
"""No warning when the provider is 'custom' — OPENAI_BASE_URL is expected."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Tests for agent.auxiliary_client._try_custom_endpoint's anthropic_messages branch.
|
||||
|
||||
When a user configures a custom endpoint with ``api_mode: anthropic_messages``
|
||||
(e.g. MiniMax, Zhipu GLM, LiteLLM in Anthropic-proxy mode), auxiliary tasks
|
||||
(compression, web_extract, session_search, title generation) must use the
|
||||
native Anthropic transport rather than being silently downgraded to an
|
||||
OpenAI-wire client that speaks the wrong protocol.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
for key in (
|
||||
"OPENAI_API_KEY", "OPENAI_BASE_URL",
|
||||
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def _install_anthropic_adapter_mocks():
|
||||
"""Patch build_anthropic_client so the test doesn't need the SDK."""
|
||||
fake_client = MagicMock(name="anthropic_client")
|
||||
return patch(
|
||||
"agent.anthropic_adapter.build_anthropic_client",
|
||||
return_value=fake_client,
|
||||
), fake_client
|
||||
|
||||
|
||||
def test_custom_endpoint_anthropic_messages_builds_anthropic_wrapper():
|
||||
"""api_mode=anthropic_messages → returns AnthropicAuxiliaryClient, not OpenAI."""
|
||||
from agent.auxiliary_client import _try_custom_endpoint, AnthropicAuxiliaryClient
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._resolve_custom_runtime",
|
||||
return_value=(
|
||||
"https://api.minimax.io/anthropic",
|
||||
"minimax-key",
|
||||
"anthropic_messages",
|
||||
),
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model",
|
||||
return_value="claude-sonnet-4-6",
|
||||
):
|
||||
adapter_patch, fake_client = _install_anthropic_adapter_mocks()
|
||||
with adapter_patch:
|
||||
client, model = _try_custom_endpoint()
|
||||
|
||||
assert isinstance(client, AnthropicAuxiliaryClient), (
|
||||
"Custom endpoint with api_mode=anthropic_messages must return the "
|
||||
f"native Anthropic wrapper, got {type(client).__name__}"
|
||||
)
|
||||
assert model == "claude-sonnet-4-6"
|
||||
# Wrapper should NOT be marked as OAuth — third-party endpoints are
|
||||
# always API-key authenticated.
|
||||
assert client.api_key == "minimax-key"
|
||||
assert client.base_url == "https://api.minimax.io/anthropic"
|
||||
|
||||
|
||||
def test_custom_endpoint_anthropic_messages_falls_back_when_sdk_missing():
|
||||
"""Graceful degradation when anthropic SDK is unavailable."""
|
||||
from agent.auxiliary_client import _try_custom_endpoint
|
||||
|
||||
import_error = ImportError("anthropic package not installed")
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._resolve_custom_runtime",
|
||||
return_value=("https://api.minimax.io/anthropic", "k", "anthropic_messages"),
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model",
|
||||
return_value="claude-sonnet-4-6",
|
||||
), patch(
|
||||
"agent.anthropic_adapter.build_anthropic_client",
|
||||
side_effect=import_error,
|
||||
):
|
||||
client, model = _try_custom_endpoint()
|
||||
|
||||
# Should fall back to an OpenAI-wire client rather than returning
|
||||
# (None, None) — the tool still needs to do *something*.
|
||||
assert client is not None
|
||||
assert model == "claude-sonnet-4-6"
|
||||
# OpenAI client, not AnthropicAuxiliaryClient.
|
||||
from agent.auxiliary_client import AnthropicAuxiliaryClient
|
||||
assert not isinstance(client, AnthropicAuxiliaryClient)
|
||||
|
||||
|
||||
def test_custom_endpoint_chat_completions_still_uses_openai_wire():
|
||||
"""Regression: default path (no api_mode) must remain OpenAI client."""
|
||||
from agent.auxiliary_client import _try_custom_endpoint, AnthropicAuxiliaryClient
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._resolve_custom_runtime",
|
||||
return_value=("https://api.example.com/v1", "key", None),
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model",
|
||||
return_value="my-model",
|
||||
):
|
||||
client, model = _try_custom_endpoint()
|
||||
|
||||
assert client is not None
|
||||
assert model == "my-model"
|
||||
assert not isinstance(client, AnthropicAuxiliaryClient)
|
||||
@@ -267,3 +267,174 @@ class TestPackaging:
|
||||
from pathlib import Path
|
||||
content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text()
|
||||
assert '"hermes-agent[bedrock]"' in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model ID dot preservation — regression for #11976
|
||||
# ---------------------------------------------------------------------------
|
||||
# AWS Bedrock inference-profile model IDs embed structural dots:
|
||||
#
|
||||
# global.anthropic.claude-opus-4-7
|
||||
# us.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# apac.anthropic.claude-haiku-4-5
|
||||
#
|
||||
# ``agent.anthropic_adapter.normalize_model_name`` converts dots to hyphens
|
||||
# unless the caller opts in via ``preserve_dots=True``. Before this fix,
|
||||
# ``AIAgent._anthropic_preserve_dots`` returned False for the ``bedrock``
|
||||
# provider, so Claude-on-Bedrock requests went out with
|
||||
# ``global-anthropic-claude-opus-4-7`` (all dots mangled to hyphens) and
|
||||
# Bedrock rejected them with:
|
||||
#
|
||||
# HTTP 400: The provided model identifier is invalid.
|
||||
#
|
||||
# The fix adds ``bedrock`` to the preserve-dots provider allowlist and
|
||||
# ``bedrock-runtime.`` to the base-URL heuristic, mirroring the shape of
|
||||
# the opencode-go fix for #5211 (commit f77be22c), which extended this
|
||||
# same allowlist.
|
||||
|
||||
|
||||
class TestBedrockPreserveDotsFlag:
|
||||
"""``AIAgent._anthropic_preserve_dots`` must return True on Bedrock so
|
||||
inference-profile IDs survive the normalize step intact."""
|
||||
|
||||
def test_bedrock_provider_preserves_dots(self):
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(provider="bedrock", base_url="")
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is True
|
||||
|
||||
def test_bedrock_runtime_us_east_1_url_preserves_dots(self):
|
||||
"""Defense-in-depth: even without an explicit ``provider="bedrock"``,
|
||||
a ``bedrock-runtime.us-east-1.amazonaws.com`` base URL must not
|
||||
mangle dots."""
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(
|
||||
provider="custom",
|
||||
base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
)
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is True
|
||||
|
||||
def test_bedrock_runtime_ap_northeast_2_url_preserves_dots(self):
|
||||
"""Reporter-reported region (ap-northeast-2) exercises the same
|
||||
base-URL heuristic."""
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(
|
||||
provider="custom",
|
||||
base_url="https://bedrock-runtime.ap-northeast-2.amazonaws.com",
|
||||
)
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is True
|
||||
|
||||
def test_non_bedrock_aws_url_does_not_preserve_dots(self):
|
||||
"""Unrelated AWS endpoints (e.g. ``s3.us-east-1.amazonaws.com``)
|
||||
must not accidentally activate the dot-preservation heuristic —
|
||||
the heuristic is scoped to the ``bedrock-runtime.`` substring
|
||||
specifically."""
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(
|
||||
provider="custom",
|
||||
base_url="https://s3.us-east-1.amazonaws.com",
|
||||
)
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is False
|
||||
|
||||
def test_anthropic_native_still_does_not_preserve_dots(self):
|
||||
"""Canary: adding Bedrock to the allowlist must not weaken the
|
||||
existing Anthropic native behaviour — ``claude-sonnet-4.6`` still
|
||||
becomes ``claude-sonnet-4-6`` for the Anthropic API."""
|
||||
from types import SimpleNamespace
|
||||
agent = SimpleNamespace(provider="anthropic", base_url="https://api.anthropic.com")
|
||||
from run_agent import AIAgent
|
||||
assert AIAgent._anthropic_preserve_dots(agent) is False
|
||||
|
||||
|
||||
class TestBedrockModelNameNormalization:
|
||||
"""End-to-end: ``normalize_model_name`` + the preserve-dots flag
|
||||
reproduce the exact production request shape for each Bedrock model
|
||||
family, confirming the fix resolves the reporter's HTTP 400."""
|
||||
|
||||
def test_global_anthropic_inference_profile_preserved(self):
|
||||
"""The reporter's exact model ID."""
|
||||
from agent.anthropic_adapter import normalize_model_name
|
||||
assert normalize_model_name(
|
||||
"global.anthropic.claude-opus-4-7", preserve_dots=True
|
||||
) == "global.anthropic.claude-opus-4-7"
|
||||
|
||||
def test_us_anthropic_dated_inference_profile_preserved(self):
|
||||
"""Regional + dated Sonnet inference profile."""
|
||||
from agent.anthropic_adapter import normalize_model_name
|
||||
assert normalize_model_name(
|
||||
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
preserve_dots=True,
|
||||
) == "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
|
||||
def test_apac_anthropic_haiku_inference_profile_preserved(self):
|
||||
"""APAC inference profile — same structural-dot shape."""
|
||||
from agent.anthropic_adapter import normalize_model_name
|
||||
assert normalize_model_name(
|
||||
"apac.anthropic.claude-haiku-4-5", preserve_dots=True
|
||||
) == "apac.anthropic.claude-haiku-4-5"
|
||||
|
||||
def test_preserve_false_mangles_as_documented(self):
|
||||
"""Canary: with ``preserve_dots=False`` the function still
|
||||
produces the broken all-hyphen form — this is the shape that
|
||||
Bedrock rejected and that the fix avoids. Keeping this test
|
||||
locks in the existing behaviour of ``normalize_model_name`` so a
|
||||
future refactor doesn't accidentally decouple the knob from its
|
||||
effect."""
|
||||
from agent.anthropic_adapter import normalize_model_name
|
||||
assert normalize_model_name(
|
||||
"global.anthropic.claude-opus-4-7", preserve_dots=False
|
||||
) == "global-anthropic-claude-opus-4-7"
|
||||
|
||||
def test_bare_foundation_model_id_preserved(self):
|
||||
"""Non-inference-profile Bedrock IDs
|
||||
(e.g. ``anthropic.claude-3-5-sonnet-20241022-v2:0``) use dots as
|
||||
vendor separators and must also survive intact under
|
||||
``preserve_dots=True``."""
|
||||
from agent.anthropic_adapter import normalize_model_name
|
||||
assert normalize_model_name(
|
||||
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
preserve_dots=True,
|
||||
) == "anthropic.claude-3-5-sonnet-20241022-v2:0"
|
||||
|
||||
|
||||
class TestBedrockBuildAnthropicKwargsEndToEnd:
|
||||
"""Integration: calling ``build_anthropic_kwargs`` with a Bedrock-
|
||||
shaped model ID and ``preserve_dots=True`` produces the unmangled
|
||||
model string in the outgoing kwargs — the exact body sent to the
|
||||
``bedrock-runtime.`` endpoint. This is the integration-level
|
||||
regression for the reporter's HTTP 400."""
|
||||
|
||||
def test_bedrock_inference_profile_survives_build_kwargs(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="global.anthropic.claude-opus-4-7",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
reasoning_config=None,
|
||||
preserve_dots=True,
|
||||
)
|
||||
assert kwargs["model"] == "global.anthropic.claude-opus-4-7", (
|
||||
"Bedrock inference-profile ID was mangled in build_anthropic_kwargs: "
|
||||
f"{kwargs['model']!r}"
|
||||
)
|
||||
|
||||
def test_bedrock_model_mangled_without_preserve_dots(self):
|
||||
"""Inverse canary: without the flag, ``build_anthropic_kwargs``
|
||||
still produces the broken form — so the fix in
|
||||
``_anthropic_preserve_dots`` is the load-bearing piece that
|
||||
wires ``preserve_dots=True`` through to this builder for the
|
||||
Bedrock case."""
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="global.anthropic.claude-opus-4-7",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
reasoning_config=None,
|
||||
preserve_dots=False,
|
||||
)
|
||||
assert kwargs["model"] == "global-anthropic-claude-opus-4-7"
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -124,6 +125,31 @@ def test_expand_file_range_and_folder_listing(sample_repo: Path):
|
||||
assert not result.warnings
|
||||
|
||||
|
||||
def test_folder_listing_falls_back_when_rg_is_blocked(sample_repo: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
real_run = subprocess.run
|
||||
|
||||
def blocked_rg(*args, **kwargs):
|
||||
cmd = args[0] if args else kwargs.get("args")
|
||||
if isinstance(cmd, list) and cmd and cmd[0] == "rg":
|
||||
raise PermissionError("rg blocked by policy")
|
||||
return real_run(*args, **kwargs)
|
||||
|
||||
with patch("agent.context_references.subprocess.run", side_effect=blocked_rg):
|
||||
result = preprocess_context_references(
|
||||
"Review @folder:src/",
|
||||
cwd=sample_repo,
|
||||
context_length=100_000,
|
||||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "src/" in result.message
|
||||
assert "main.py" in result.message
|
||||
assert "helper.py" in result.message
|
||||
assert not result.warnings
|
||||
|
||||
|
||||
def test_expand_quoted_file_reference_with_spaces(tmp_path: Path):
|
||||
from agent.context_references import preprocess_context_references
|
||||
|
||||
|
||||
@@ -1,129 +1,25 @@
|
||||
"""Tests for credential pool preservation through smart routing and 429 recovery.
|
||||
"""Tests for credential pool preservation through turn config and 429 recovery.
|
||||
|
||||
Covers:
|
||||
1. credential_pool flows through resolve_turn_route (no-route and fallback paths)
|
||||
2. CLI _resolve_turn_agent_config passes credential_pool to primary dict
|
||||
3. Gateway _resolve_turn_agent_config passes credential_pool to primary dict
|
||||
4. Eager fallback deferred when credential pool has credentials
|
||||
5. Eager fallback fires when no credential pool exists
|
||||
6. Full 429 rotation cycle: retry-same → rotate → exhaust → fallback
|
||||
1. CLI _resolve_turn_agent_config passes credential_pool to runtime dict
|
||||
2. Gateway _resolve_turn_agent_config passes credential_pool to runtime dict
|
||||
3. Eager fallback deferred when credential pool has credentials
|
||||
4. Eager fallback fires when no credential pool exists
|
||||
5. Full 429 rotation cycle: retry-same → rotate → exhaust → fallback
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. smart_model_routing: credential_pool preserved in no-route path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSmartRoutingPoolPreservation:
|
||||
def test_no_route_preserves_credential_pool(self):
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
|
||||
fake_pool = MagicMock(name="CredentialPool")
|
||||
primary = {
|
||||
"model": "gpt-5.4",
|
||||
"api_key": "sk-test",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": fake_pool,
|
||||
}
|
||||
# routing disabled
|
||||
result = resolve_turn_route("hello", None, primary)
|
||||
assert result["runtime"]["credential_pool"] is fake_pool
|
||||
|
||||
def test_no_route_none_pool(self):
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
|
||||
primary = {
|
||||
"model": "gpt-5.4",
|
||||
"api_key": "sk-test",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
}
|
||||
result = resolve_turn_route("hello", None, primary)
|
||||
assert result["runtime"]["credential_pool"] is None
|
||||
|
||||
def test_routing_disabled_preserves_pool(self):
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
|
||||
fake_pool = MagicMock(name="CredentialPool")
|
||||
primary = {
|
||||
"model": "gpt-5.4",
|
||||
"api_key": "sk-test",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": fake_pool,
|
||||
}
|
||||
# routing explicitly disabled
|
||||
result = resolve_turn_route("hello", {"enabled": False}, primary)
|
||||
assert result["runtime"]["credential_pool"] is fake_pool
|
||||
|
||||
def test_route_fallback_on_resolve_error_preserves_pool(self, monkeypatch):
|
||||
"""When smart routing picks a cheap model but resolve_runtime_provider
|
||||
fails, the fallback to primary must still include credential_pool."""
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
|
||||
fake_pool = MagicMock(name="CredentialPool")
|
||||
primary = {
|
||||
"model": "gpt-5.4",
|
||||
"api_key": "sk-test",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": fake_pool,
|
||||
}
|
||||
routing_config = {
|
||||
"enabled": True,
|
||||
"cheap_model": "openai/gpt-4.1-mini",
|
||||
"cheap_provider": "openrouter",
|
||||
"max_tokens": 200,
|
||||
"patterns": ["^(hi|hello|hey)"],
|
||||
}
|
||||
# Force resolve_runtime_provider to fail so it falls back to primary
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
MagicMock(side_effect=RuntimeError("no credentials")),
|
||||
)
|
||||
result = resolve_turn_route("hi", routing_config, primary)
|
||||
assert result["runtime"]["credential_pool"] is fake_pool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2 & 3. CLI and Gateway _resolve_turn_agent_config include credential_pool
|
||||
# 1. CLI _resolve_turn_agent_config includes credential_pool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCliTurnRoutePool:
|
||||
def test_resolve_turn_includes_pool(self, monkeypatch, tmp_path):
|
||||
"""CLI's _resolve_turn_agent_config must pass credential_pool to primary."""
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
captured = {}
|
||||
|
||||
def spy_resolve(user_message, routing_config, primary):
|
||||
captured["primary"] = primary
|
||||
return resolve_turn_route(user_message, routing_config, primary)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent.smart_model_routing.resolve_turn_route", spy_resolve
|
||||
)
|
||||
|
||||
# Build a minimal HermesCLI-like object with the method
|
||||
def test_resolve_turn_includes_pool(self):
|
||||
"""CLI's _resolve_turn_agent_config must pass credential_pool in runtime."""
|
||||
fake_pool = MagicMock(name="FakePool")
|
||||
shell = SimpleNamespace(
|
||||
model="gpt-5.4",
|
||||
api_key="sk-test",
|
||||
@@ -132,58 +28,46 @@ class TestCliTurnRoutePool:
|
||||
api_mode="codex_responses",
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=MagicMock(name="FakePool"),
|
||||
_smart_model_routing={"enabled": False},
|
||||
_credential_pool=fake_pool,
|
||||
service_tier=None,
|
||||
)
|
||||
|
||||
# Import and bind the real method
|
||||
from cli import HermesCLI
|
||||
bound = HermesCLI._resolve_turn_agent_config.__get__(shell)
|
||||
bound("test message")
|
||||
route = bound("test message")
|
||||
|
||||
assert "credential_pool" in captured["primary"]
|
||||
assert captured["primary"]["credential_pool"] is shell._credential_pool
|
||||
assert route["runtime"]["credential_pool"] is fake_pool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Gateway _resolve_turn_agent_config includes credential_pool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGatewayTurnRoutePool:
|
||||
def test_resolve_turn_includes_pool(self, monkeypatch):
|
||||
def test_resolve_turn_includes_pool(self):
|
||||
"""Gateway's _resolve_turn_agent_config must pass credential_pool."""
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
captured = {}
|
||||
|
||||
def spy_resolve(user_message, routing_config, primary):
|
||||
captured["primary"] = primary
|
||||
return resolve_turn_route(user_message, routing_config, primary)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent.smart_model_routing.resolve_turn_route", spy_resolve
|
||||
)
|
||||
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = SimpleNamespace(
|
||||
_smart_model_routing={"enabled": False},
|
||||
)
|
||||
|
||||
fake_pool = MagicMock(name="FakePool")
|
||||
runner = SimpleNamespace(_service_tier=None)
|
||||
runtime_kwargs = {
|
||||
"api_key": "sk-test",
|
||||
"api_key": "***",
|
||||
"base_url": None,
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": MagicMock(name="FakePool"),
|
||||
"credential_pool": fake_pool,
|
||||
}
|
||||
|
||||
bound = GatewayRunner._resolve_turn_agent_config.__get__(runner)
|
||||
bound("test message", "gpt-5.4", runtime_kwargs)
|
||||
route = bound("test message", "gpt-5.4", runtime_kwargs)
|
||||
|
||||
assert "credential_pool" in captured["primary"]
|
||||
assert captured["primary"]["credential_pool"] is runtime_kwargs["credential_pool"]
|
||||
assert route["runtime"]["credential_pool"] is fake_pool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4 & 5. Eager fallback deferred/fires based on credential pool
|
||||
# 3 & 4. Eager fallback deferred/fires based on credential pool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEagerFallbackWithPool:
|
||||
@@ -251,7 +135,7 @@ class TestEagerFallbackWithPool:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Full 429 rotation cycle via _recover_with_credential_pool
|
||||
# 5. Full 429 rotation cycle via _recover_with_credential_pool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPoolRotationCycle:
|
||||
|
||||
@@ -83,6 +83,13 @@ class TestBuildToolPreview:
|
||||
assert result is not None
|
||||
assert "user" in result
|
||||
|
||||
def test_memory_replace_missing_old_text_marked(self):
|
||||
# Avoid empty quotes "" in the preview when old_text is missing/None.
|
||||
result = build_tool_preview("memory", {"action": "replace", "target": "memory"})
|
||||
assert result == '~memory: "<missing old_text>"'
|
||||
result = build_tool_preview("memory", {"action": "remove", "target": "memory", "old_text": None})
|
||||
assert result == '-memory: "<missing old_text>"'
|
||||
|
||||
def test_session_search_preview(self):
|
||||
result = build_tool_preview("session_search", {"query": "find something"})
|
||||
assert result is not None
|
||||
|
||||
@@ -849,3 +849,97 @@ class TestAdversarialEdgeCases:
|
||||
)
|
||||
result = classify_api_error(e, provider="openrouter")
|
||||
assert result.reason == FailoverReason.model_not_found
|
||||
|
||||
# ── Regression: dict-typed message field (Issue #11233) ──
|
||||
|
||||
def test_pydantic_dict_message_no_crash(self):
|
||||
"""Pydantic validation errors return message as dict, not string.
|
||||
|
||||
Regression: classify_api_error must not crash when body['message']
|
||||
is a dict (e.g. {"detail": [...]} from FastAPI/Pydantic). The
|
||||
'or ""' fallback only handles None/falsy values — a non-empty
|
||||
dict is truthy and passed to .lower(), causing AttributeError.
|
||||
"""
|
||||
e = MockAPIError(
|
||||
"Unprocessable Entity",
|
||||
status_code=422,
|
||||
body={
|
||||
"object": "error",
|
||||
"message": {
|
||||
"detail": [
|
||||
{
|
||||
"type": "extra_forbidden",
|
||||
"loc": ["body", "think"],
|
||||
"msg": "Extra inputs are not permitted",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.format_error
|
||||
assert result.status_code == 422
|
||||
assert result.retryable is False
|
||||
|
||||
def test_nested_error_dict_message_no_crash(self):
|
||||
"""Nested body['error']['message'] as dict must not crash.
|
||||
|
||||
Some providers wrap Pydantic errors in an 'error' object.
|
||||
"""
|
||||
e = MockAPIError(
|
||||
"Validation error",
|
||||
status_code=400,
|
||||
body={
|
||||
"error": {
|
||||
"message": {
|
||||
"detail": [
|
||||
{"type": "missing", "loc": ["body", "required"]}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
result = classify_api_error(e, approx_tokens=1000)
|
||||
assert result.reason == FailoverReason.format_error
|
||||
assert result.status_code == 400
|
||||
|
||||
def test_metadata_raw_dict_message_no_crash(self):
|
||||
"""OpenRouter metadata.raw with dict message must not crash."""
|
||||
e = MockAPIError(
|
||||
"Provider error",
|
||||
status_code=400,
|
||||
body={
|
||||
"error": {
|
||||
"message": "Provider error",
|
||||
"metadata": {
|
||||
"raw": '{"error":{"message":{"detail":[{"type":"invalid"}]}}}'
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.format_error
|
||||
|
||||
# Broader non-string type guards — defense against other provider quirks.
|
||||
|
||||
def test_list_message_no_crash(self):
|
||||
"""Some providers return message as a list of error entries."""
|
||||
e = MockAPIError(
|
||||
"validation",
|
||||
status_code=400,
|
||||
body={"message": [{"msg": "field required"}]},
|
||||
)
|
||||
result = classify_api_error(e)
|
||||
assert result is not None
|
||||
|
||||
def test_int_message_no_crash(self):
|
||||
"""Any non-string type must be coerced safely."""
|
||||
e = MockAPIError("server error", status_code=500, body={"message": 42})
|
||||
result = classify_api_error(e)
|
||||
assert result is not None
|
||||
|
||||
def test_none_message_still_works(self):
|
||||
"""Regression: None fallback (the 'or \"\"' path) must still work."""
|
||||
e = MockAPIError("server error", status_code=500, body={"message": None})
|
||||
result = classify_api_error(e)
|
||||
assert result is not None
|
||||
|
||||
@@ -652,6 +652,42 @@ class TestBuildGeminiRequest:
|
||||
assert decls[0]["description"] == "foo"
|
||||
assert decls[0]["parameters"] == {"type": "object"}
|
||||
|
||||
def test_tools_strip_json_schema_only_fields_from_parameters(self):
|
||||
from agent.gemini_cloudcode_adapter import build_gemini_request
|
||||
|
||||
req = build_gemini_request(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=[
|
||||
{"type": "function", "function": {
|
||||
"name": "fn1",
|
||||
"description": "foo",
|
||||
"parameters": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"$schema": "ignored",
|
||||
"description": "City name",
|
||||
"additionalProperties": False,
|
||||
}
|
||||
},
|
||||
"required": ["city"],
|
||||
},
|
||||
}},
|
||||
],
|
||||
)
|
||||
params = req["tools"][0]["functionDeclarations"][0]["parameters"]
|
||||
assert "$schema" not in params
|
||||
assert "additionalProperties" not in params
|
||||
assert params["type"] == "object"
|
||||
assert params["required"] == ["city"]
|
||||
assert params["properties"]["city"] == {
|
||||
"type": "string",
|
||||
"description": "City name",
|
||||
}
|
||||
|
||||
def test_tool_choice_auto(self):
|
||||
from agent.gemini_cloudcode_adapter import build_gemini_request
|
||||
|
||||
@@ -814,6 +850,69 @@ class TestTranslateGeminiResponse:
|
||||
assert _map_gemini_finish_reason("RECITATION") == "content_filter"
|
||||
|
||||
|
||||
class TestTranslateStreamEvent:
|
||||
def test_parallel_calls_to_same_tool_get_unique_indices(self):
|
||||
"""Gemini may emit several functionCall parts with the same name in a
|
||||
single turn (e.g. parallel file reads). Each must get its own OpenAI
|
||||
``index`` — otherwise downstream aggregators collapse them into one.
|
||||
"""
|
||||
from agent.gemini_cloudcode_adapter import _translate_stream_event
|
||||
|
||||
event = {
|
||||
"response": {
|
||||
"candidates": [{
|
||||
"content": {"parts": [
|
||||
{"functionCall": {"name": "read_file", "args": {"path": "a"}}},
|
||||
{"functionCall": {"name": "read_file", "args": {"path": "b"}}},
|
||||
{"functionCall": {"name": "read_file", "args": {"path": "c"}}},
|
||||
]},
|
||||
}],
|
||||
}
|
||||
}
|
||||
counter = [0]
|
||||
chunks = _translate_stream_event(event, model="gemini-2.5-flash",
|
||||
tool_call_counter=counter)
|
||||
indices = [c.choices[0].delta.tool_calls[0].index for c in chunks]
|
||||
assert indices == [0, 1, 2]
|
||||
assert counter[0] == 3
|
||||
|
||||
def test_counter_persists_across_events(self):
|
||||
"""Index assignment must continue across SSE events in the same stream."""
|
||||
from agent.gemini_cloudcode_adapter import _translate_stream_event
|
||||
|
||||
def _event(name):
|
||||
return {"response": {"candidates": [{
|
||||
"content": {"parts": [{"functionCall": {"name": name, "args": {}}}]},
|
||||
}]}}
|
||||
|
||||
counter = [0]
|
||||
chunks_a = _translate_stream_event(_event("foo"), model="m", tool_call_counter=counter)
|
||||
chunks_b = _translate_stream_event(_event("bar"), model="m", tool_call_counter=counter)
|
||||
chunks_c = _translate_stream_event(_event("foo"), model="m", tool_call_counter=counter)
|
||||
|
||||
assert chunks_a[0].choices[0].delta.tool_calls[0].index == 0
|
||||
assert chunks_b[0].choices[0].delta.tool_calls[0].index == 1
|
||||
assert chunks_c[0].choices[0].delta.tool_calls[0].index == 2
|
||||
|
||||
def test_finish_reason_switches_to_tool_calls_when_any_seen(self):
|
||||
from agent.gemini_cloudcode_adapter import _translate_stream_event
|
||||
|
||||
counter = [0]
|
||||
# First event emits one tool call.
|
||||
_translate_stream_event(
|
||||
{"response": {"candidates": [{
|
||||
"content": {"parts": [{"functionCall": {"name": "x", "args": {}}}]},
|
||||
}]}},
|
||||
model="m", tool_call_counter=counter,
|
||||
)
|
||||
# Second event carries only the terminal finishReason.
|
||||
chunks = _translate_stream_event(
|
||||
{"response": {"candidates": [{"finishReason": "STOP"}]}},
|
||||
model="m", tool_call_counter=counter,
|
||||
)
|
||||
assert chunks[-1].choices[0].finish_reason == "tool_calls"
|
||||
|
||||
|
||||
class TestGeminiCloudCodeClient:
|
||||
def test_client_exposes_openai_interface(self):
|
||||
from agent.gemini_cloudcode_adapter import GeminiCloudCodeClient
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Tests for the native Google AI Studio Gemini adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
def __init__(self, status_code=200, payload=None, headers=None, text=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload or {}
|
||||
self.headers = headers or {}
|
||||
self.text = text if text is not None else json.dumps(self._payload)
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def test_build_native_request_preserves_thought_signature_on_tool_replay():
|
||||
from agent.gemini_native_adapter import build_gemini_request
|
||||
|
||||
request = build_gemini_request(
|
||||
messages=[
|
||||
{"role": "system", "content": "Be helpful."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "Paris"}',
|
||||
},
|
||||
"extra_content": {
|
||||
"google": {"thought_signature": "sig-123"}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
tools=[],
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
parts = request["contents"][0]["parts"]
|
||||
assert parts[0]["functionCall"]["name"] == "get_weather"
|
||||
assert parts[0]["thoughtSignature"] == "sig-123"
|
||||
|
||||
|
||||
def test_build_native_request_uses_original_function_name_for_tool_result():
|
||||
from agent.gemini_native_adapter import build_gemini_request
|
||||
|
||||
request = build_gemini_request(
|
||||
messages=[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "Paris"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": '{"forecast": "sunny"}',
|
||||
},
|
||||
],
|
||||
tools=[],
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
tool_response = request["contents"][1]["parts"][0]["functionResponse"]
|
||||
assert tool_response["name"] == "get_weather"
|
||||
|
||||
|
||||
def test_build_native_request_strips_json_schema_only_fields_from_tool_parameters():
|
||||
from agent.gemini_native_adapter import build_gemini_request
|
||||
|
||||
request = build_gemini_request(
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup_weather",
|
||||
"description": "Weather lookup",
|
||||
"parameters": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"$schema": "ignored",
|
||||
"description": "City name",
|
||||
}
|
||||
},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
params = request["tools"][0]["functionDeclarations"][0]["parameters"]
|
||||
assert "$schema" not in params
|
||||
assert "additionalProperties" not in params
|
||||
assert params["type"] == "object"
|
||||
assert params["properties"]["city"] == {
|
||||
"type": "string",
|
||||
"description": "City name",
|
||||
}
|
||||
|
||||
|
||||
def test_translate_native_response_surfaces_reasoning_and_tool_calls():
|
||||
from agent.gemini_native_adapter import translate_gemini_response
|
||||
|
||||
payload = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"thought": True, "text": "thinking..."},
|
||||
{"functionCall": {"name": "search", "args": {"q": "hermes"}}},
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 5,
|
||||
"totalTokenCount": 15,
|
||||
},
|
||||
}
|
||||
|
||||
response = translate_gemini_response(payload, model="gemini-2.5-flash")
|
||||
choice = response.choices[0]
|
||||
assert choice.finish_reason == "tool_calls"
|
||||
assert choice.message.reasoning == "thinking..."
|
||||
assert choice.message.tool_calls[0].function.name == "search"
|
||||
assert json.loads(choice.message.tool_calls[0].function.arguments) == {"q": "hermes"}
|
||||
|
||||
|
||||
def test_native_client_uses_x_goog_api_key_and_native_models_endpoint(monkeypatch):
|
||||
from agent.gemini_native_adapter import GeminiNativeClient
|
||||
|
||||
recorded = {}
|
||||
|
||||
class DummyHTTP:
|
||||
def post(self, url, json=None, headers=None, timeout=None):
|
||||
recorded["url"] = url
|
||||
recorded["json"] = json
|
||||
recorded["headers"] = headers
|
||||
return DummyResponse(
|
||||
payload={
|
||||
"candidates": [
|
||||
{
|
||||
"content": {"parts": [{"text": "hello"}]},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 1,
|
||||
"candidatesTokenCount": 1,
|
||||
"totalTokenCount": 2,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("agent.gemini_native_adapter.httpx.Client", lambda *a, **k: DummyHTTP())
|
||||
|
||||
client = GeminiNativeClient(api_key="AIza-test", base_url="https://generativelanguage.googleapis.com/v1beta")
|
||||
response = client.chat.completions.create(
|
||||
model="gemini-2.5-flash",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
)
|
||||
|
||||
assert recorded["url"] == "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"
|
||||
assert recorded["headers"]["x-goog-api-key"] == "AIza-test"
|
||||
assert "Authorization" not in recorded["headers"]
|
||||
assert response.choices[0].message.content == "hello"
|
||||
|
||||
|
||||
def test_native_http_error_keeps_status_and_retry_after():
|
||||
from agent.gemini_native_adapter import gemini_http_error
|
||||
|
||||
response = DummyResponse(
|
||||
status_code=429,
|
||||
headers={"Retry-After": "17"},
|
||||
payload={
|
||||
"error": {
|
||||
"code": 429,
|
||||
"message": "quota exhausted",
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
"details": [
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||
"reason": "RESOURCE_EXHAUSTED",
|
||||
"metadata": {"service": "generativelanguage.googleapis.com"},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
err = gemini_http_error(response)
|
||||
assert getattr(err, "status_code", None) == 429
|
||||
assert getattr(err, "retry_after", None) == 17.0
|
||||
assert "quota exhausted" in str(err)
|
||||
|
||||
|
||||
def test_native_client_accepts_injected_http_client():
|
||||
from agent.gemini_native_adapter import GeminiNativeClient
|
||||
|
||||
injected = SimpleNamespace(close=lambda: None)
|
||||
client = GeminiNativeClient(api_key="AIza-test", http_client=injected)
|
||||
assert client._http is injected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_native_client_streams_without_requiring_async_iterator_from_sync_client():
|
||||
from agent.gemini_native_adapter import AsyncGeminiNativeClient
|
||||
|
||||
chunk = SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content="hi"), finish_reason=None)])
|
||||
sync_stream = iter([chunk])
|
||||
|
||||
def _advance(iterator):
|
||||
try:
|
||||
return False, next(iterator)
|
||||
except StopIteration:
|
||||
return True, None
|
||||
|
||||
sync_client = SimpleNamespace(
|
||||
api_key="AIza-test",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta",
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **kwargs: sync_stream)),
|
||||
_advance_stream_iterator=_advance,
|
||||
close=lambda: None,
|
||||
)
|
||||
|
||||
async_client = AsyncGeminiNativeClient(sync_client)
|
||||
stream = await async_client.chat.completions.create(stream=True)
|
||||
collected = []
|
||||
async for item in stream:
|
||||
collected.append(item)
|
||||
assert collected == [chunk]
|
||||
|
||||
|
||||
def test_stream_event_translation_emits_tool_call_delta_with_stable_index():
|
||||
from agent.gemini_native_adapter import translate_stream_event
|
||||
|
||||
tool_call_indices = {}
|
||||
event = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"functionCall": {"name": "search", "args": {"q": "abc"}}}
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
first = translate_stream_event(event, model="gemini-2.5-flash", tool_call_indices=tool_call_indices)
|
||||
second = translate_stream_event(event, model="gemini-2.5-flash", tool_call_indices=tool_call_indices)
|
||||
|
||||
assert first[0].choices[0].delta.tool_calls[0].index == 0
|
||||
assert second[0].choices[0].delta.tool_calls[0].index == 0
|
||||
assert first[0].choices[0].delta.tool_calls[0].id == second[0].choices[0].delta.tool_calls[0].id
|
||||
assert first[0].choices[0].delta.tool_calls[0].function.arguments == '{"q": "abc"}'
|
||||
assert second[0].choices[0].delta.tool_calls[0].function.arguments == ""
|
||||
assert first[-1].choices[0].finish_reason == "tool_calls"
|
||||
|
||||
|
||||
def test_stream_event_translation_keeps_identical_calls_in_distinct_parts():
|
||||
from agent.gemini_native_adapter import translate_stream_event
|
||||
|
||||
event = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"functionCall": {"name": "search", "args": {"q": "abc"}}},
|
||||
{"functionCall": {"name": "search", "args": {"q": "abc"}}},
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
chunks = translate_stream_event(event, model="gemini-2.5-flash", tool_call_indices={})
|
||||
tool_chunks = [chunk for chunk in chunks if chunk.choices[0].delta.tool_calls]
|
||||
assert tool_chunks[0].choices[0].delta.tool_calls[0].index == 0
|
||||
assert tool_chunks[1].choices[0].delta.tool_calls[0].index == 1
|
||||
assert tool_chunks[0].choices[0].delta.tool_calls[0].id != tool_chunks[1].choices[0].delta.tool_calls[0].id
|
||||
@@ -1052,110 +1052,4 @@ class TestOpenAIModelExecutionGuidance:
|
||||
# =========================================================================
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Workspace guidance assembler
|
||||
# =========================================================================
|
||||
|
||||
from agent.prompt_builder import (
|
||||
build_workspace_guidance,
|
||||
WORKSPACE_SEARCH_GUIDANCE_CORE,
|
||||
WORKSPACE_RETRIEVE_GUIDANCE,
|
||||
WORKSPACE_LIST_GUIDANCE,
|
||||
WORKSPACE_INDEX_GUIDANCE,
|
||||
)
|
||||
|
||||
|
||||
class TestWorkspaceGuidance:
|
||||
def test_returns_none_when_search_unavailable(self):
|
||||
"""If workspace_search is not in tools, guidance is not injected at all."""
|
||||
assert build_workspace_guidance(set()) is None
|
||||
assert build_workspace_guidance({"memory", "todo"}) is None
|
||||
|
||||
def test_core_only_when_only_search_available(self):
|
||||
"""With just workspace_search, guidance contains core paragraph only."""
|
||||
out = build_workspace_guidance({"workspace_search"})
|
||||
assert out is not None
|
||||
assert WORKSPACE_SEARCH_GUIDANCE_CORE in out
|
||||
assert WORKSPACE_RETRIEVE_GUIDANCE not in out
|
||||
assert WORKSPACE_LIST_GUIDANCE not in out
|
||||
assert WORKSPACE_INDEX_GUIDANCE not in out
|
||||
|
||||
def test_core_plus_retrieve(self):
|
||||
"""Adds retrieve paragraph when workspace_retrieve is also present."""
|
||||
out = build_workspace_guidance({"workspace_search", "workspace_retrieve"})
|
||||
assert out is not None
|
||||
assert WORKSPACE_SEARCH_GUIDANCE_CORE in out
|
||||
assert WORKSPACE_RETRIEVE_GUIDANCE in out
|
||||
assert WORKSPACE_LIST_GUIDANCE not in out
|
||||
|
||||
def test_core_plus_list(self):
|
||||
out = build_workspace_guidance({"workspace_search", "workspace_list"})
|
||||
assert out is not None
|
||||
assert WORKSPACE_LIST_GUIDANCE in out
|
||||
|
||||
def test_core_plus_index(self):
|
||||
out = build_workspace_guidance({"workspace_search", "workspace_index"})
|
||||
assert out is not None
|
||||
assert WORKSPACE_INDEX_GUIDANCE in out
|
||||
|
||||
def test_all_tools_available(self):
|
||||
"""Full workspace toolset: all paragraphs present in stable order."""
|
||||
out = build_workspace_guidance({
|
||||
"workspace_search", "workspace_retrieve",
|
||||
"workspace_list", "workspace_index", "workspace_delete",
|
||||
})
|
||||
assert out is not None
|
||||
assert WORKSPACE_SEARCH_GUIDANCE_CORE in out
|
||||
assert WORKSPACE_RETRIEVE_GUIDANCE in out
|
||||
assert WORKSPACE_LIST_GUIDANCE in out
|
||||
assert WORKSPACE_INDEX_GUIDANCE in out
|
||||
core_pos = out.index(WORKSPACE_SEARCH_GUIDANCE_CORE)
|
||||
retrieve_pos = out.index(WORKSPACE_RETRIEVE_GUIDANCE)
|
||||
list_pos = out.index(WORKSPACE_LIST_GUIDANCE)
|
||||
index_pos = out.index(WORKSPACE_INDEX_GUIDANCE)
|
||||
assert core_pos < retrieve_pos < list_pos < index_pos
|
||||
|
||||
def test_delete_not_prompted(self):
|
||||
"""workspace_delete is destructive — we do NOT nudge the model toward it."""
|
||||
out = build_workspace_guidance({
|
||||
"workspace_search", "workspace_delete",
|
||||
})
|
||||
assert out is not None
|
||||
assert "delete" not in out.lower()
|
||||
|
||||
def test_output_is_single_string(self):
|
||||
out = build_workspace_guidance({"workspace_search"})
|
||||
assert isinstance(out, str)
|
||||
assert len(out.strip()) > 0
|
||||
|
||||
def test_wiring_into_system_prompt(self):
|
||||
"""End-to-end: the assembler output reaches the system prompt when
|
||||
workspace_search is in valid_tool_names.
|
||||
|
||||
Uses the same tool_guidance collection pattern as _build_system_prompt
|
||||
in run_agent.py so we verify the contract without booting AIAgent.
|
||||
"""
|
||||
from agent.prompt_builder import (
|
||||
MEMORY_GUIDANCE,
|
||||
build_workspace_guidance,
|
||||
)
|
||||
valid_tool_names = {"memory", "workspace_search", "workspace_retrieve"}
|
||||
tool_guidance = []
|
||||
if "memory" in valid_tool_names:
|
||||
tool_guidance.append(MEMORY_GUIDANCE)
|
||||
ws = build_workspace_guidance(valid_tool_names)
|
||||
if ws:
|
||||
tool_guidance.append(ws)
|
||||
combined = " ".join(tool_guidance)
|
||||
assert WORKSPACE_SEARCH_GUIDANCE_CORE in combined
|
||||
assert WORKSPACE_RETRIEVE_GUIDANCE in combined
|
||||
assert MEMORY_GUIDANCE in combined
|
||||
|
||||
def test_wiring_skips_when_workspace_unavailable(self):
|
||||
valid_tool_names = {"memory"}
|
||||
tool_guidance = []
|
||||
ws = build_workspace_guidance(valid_tool_names)
|
||||
if ws:
|
||||
tool_guidance.append(ws)
|
||||
combined = " ".join(tool_guidance)
|
||||
assert WORKSPACE_SEARCH_GUIDANCE_CORE not in combined
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
from agent.smart_model_routing import choose_cheap_model_route
|
||||
|
||||
|
||||
_BASE_CONFIG = {
|
||||
"enabled": True,
|
||||
"cheap_model": {
|
||||
"provider": "openrouter",
|
||||
"model": "google/gemini-2.5-flash",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_returns_none_when_disabled():
|
||||
cfg = {**_BASE_CONFIG, "enabled": False}
|
||||
assert choose_cheap_model_route("what time is it in tokyo?", cfg) is None
|
||||
|
||||
|
||||
def test_routes_short_simple_prompt():
|
||||
result = choose_cheap_model_route("what time is it in tokyo?", _BASE_CONFIG)
|
||||
assert result is not None
|
||||
assert result["provider"] == "openrouter"
|
||||
assert result["model"] == "google/gemini-2.5-flash"
|
||||
assert result["routing_reason"] == "simple_turn"
|
||||
|
||||
|
||||
def test_skips_long_prompt():
|
||||
prompt = "please summarize this carefully " * 20
|
||||
assert choose_cheap_model_route(prompt, _BASE_CONFIG) is None
|
||||
|
||||
|
||||
def test_skips_code_like_prompt():
|
||||
prompt = "debug this traceback: ```python\nraise ValueError('bad')\n```"
|
||||
assert choose_cheap_model_route(prompt, _BASE_CONFIG) is None
|
||||
|
||||
|
||||
def test_skips_tool_heavy_prompt_keywords():
|
||||
prompt = "implement a patch for this docker error"
|
||||
assert choose_cheap_model_route(prompt, _BASE_CONFIG) is None
|
||||
|
||||
|
||||
def test_resolve_turn_route_falls_back_to_primary_when_route_runtime_cannot_be_resolved(monkeypatch):
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("bad route")),
|
||||
)
|
||||
result = resolve_turn_route(
|
||||
"what time is it in tokyo?",
|
||||
_BASE_CONFIG,
|
||||
{
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"provider": "openrouter",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"api_key": "sk-primary",
|
||||
},
|
||||
)
|
||||
assert result["model"] == "anthropic/claude-sonnet-4"
|
||||
assert result["runtime"]["provider"] == "openrouter"
|
||||
assert result["label"] is None
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for CLI external-editor support."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
class _FakeBuffer:
|
||||
def __init__(self, text=""):
|
||||
self.calls = []
|
||||
self.text = text
|
||||
self.cursor_position = len(text)
|
||||
|
||||
def open_in_editor(self, validate_and_handle=False):
|
||||
self.calls.append(validate_and_handle)
|
||||
|
||||
|
||||
class _FakeApp:
|
||||
def __init__(self):
|
||||
self.current_buffer = _FakeBuffer()
|
||||
|
||||
|
||||
def _make_cli(with_app=True):
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj._app = _FakeApp() if with_app else None
|
||||
cli_obj._command_running = False
|
||||
cli_obj._command_status = ""
|
||||
cli_obj._command_display = ""
|
||||
cli_obj._sudo_state = None
|
||||
cli_obj._secret_state = None
|
||||
cli_obj._approval_state = None
|
||||
cli_obj._clarify_state = None
|
||||
cli_obj._skip_paste_collapse = False
|
||||
return cli_obj
|
||||
|
||||
def test_open_external_editor_uses_prompt_toolkit_buffer_editor():
|
||||
cli_obj = _make_cli()
|
||||
|
||||
assert cli_obj._open_external_editor() is True
|
||||
assert cli_obj._app.current_buffer.calls == [False]
|
||||
|
||||
|
||||
def test_open_external_editor_rejects_when_no_tui():
|
||||
cli_obj = _make_cli(with_app=False)
|
||||
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
assert cli_obj._open_external_editor() is False
|
||||
|
||||
assert mock_cprint.called
|
||||
assert "interactive cli" in str(mock_cprint.call_args).lower()
|
||||
|
||||
|
||||
def test_open_external_editor_rejects_modal_prompts():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._approval_state = {"selected": 0}
|
||||
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
assert cli_obj._open_external_editor() is False
|
||||
|
||||
assert mock_cprint.called
|
||||
assert "active prompt" in str(mock_cprint.call_args).lower()
|
||||
|
||||
def test_open_external_editor_uses_explicit_buffer_when_provided():
|
||||
cli_obj = _make_cli()
|
||||
external_buffer = _FakeBuffer()
|
||||
|
||||
assert cli_obj._open_external_editor(buffer=external_buffer) is True
|
||||
assert external_buffer.calls == [False]
|
||||
assert cli_obj._app.current_buffer.calls == []
|
||||
|
||||
|
||||
def test_expand_paste_references_replaces_placeholder_with_file_contents(tmp_path):
|
||||
cli_obj = _make_cli()
|
||||
paste_file = tmp_path / "paste.txt"
|
||||
paste_file.write_text("line one\nline two", encoding="utf-8")
|
||||
|
||||
text = f"before [Pasted text #1: 2 lines → {paste_file}] after"
|
||||
expanded = cli_obj._expand_paste_references(text)
|
||||
|
||||
assert expanded == "before line one\nline two after"
|
||||
|
||||
|
||||
def test_open_external_editor_expands_paste_placeholders_before_open(tmp_path):
|
||||
cli_obj = _make_cli()
|
||||
paste_file = tmp_path / "paste.txt"
|
||||
paste_file.write_text("alpha\nbeta", encoding="utf-8")
|
||||
buffer = _FakeBuffer(text=f"[Pasted text #1: 2 lines → {paste_file}]")
|
||||
|
||||
assert cli_obj._open_external_editor(buffer=buffer) is True
|
||||
assert buffer.text == "alpha\nbeta"
|
||||
assert buffer.cursor_position == len("alpha\nbeta")
|
||||
assert buffer.calls == [False]
|
||||
|
||||
|
||||
def test_open_external_editor_sets_skip_collapse_flag_during_expansion(tmp_path):
|
||||
cli_obj = _make_cli()
|
||||
paste_file = tmp_path / "paste.txt"
|
||||
paste_file.write_text("a\nb\nc\nd\ne\nf", encoding="utf-8")
|
||||
buffer = _FakeBuffer(text=f"[Pasted text #1: 6 lines \u2192 {paste_file}]")
|
||||
|
||||
# After expansion the flag should have been set (to prevent re-collapse)
|
||||
assert cli_obj._open_external_editor(buffer=buffer) is True
|
||||
# Flag is consumed by _on_text_changed, but since no handler is attached
|
||||
# in tests it stays True until the handler resets it.
|
||||
assert cli_obj._skip_paste_collapse is True
|
||||
@@ -0,0 +1,117 @@
|
||||
from io import StringIO
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from cli import _render_final_assistant_content
|
||||
|
||||
|
||||
def _render_to_text(renderable) -> str:
|
||||
buf = StringIO()
|
||||
Console(file=buf, width=80, force_terminal=False, color_system=None).print(renderable)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_final_assistant_content_uses_markdown_renderable():
|
||||
renderable = _render_final_assistant_content("# Title\n\n- one\n- two")
|
||||
|
||||
assert isinstance(renderable, Markdown)
|
||||
output = _render_to_text(renderable)
|
||||
assert "Title" in output
|
||||
assert "one" in output
|
||||
assert "two" in output
|
||||
|
||||
|
||||
def test_final_assistant_content_strips_ansi_before_markdown_rendering():
|
||||
renderable = _render_final_assistant_content("\x1b[31m# Title\x1b[0m")
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "Title" in output
|
||||
assert "\x1b" not in output
|
||||
|
||||
|
||||
def test_final_assistant_content_can_strip_markdown_syntax():
|
||||
renderable = _render_final_assistant_content(
|
||||
"***Bold italic***\n~~Strike~~\n- item\n# Title\n`code`",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "Bold italic" in output
|
||||
assert "Strike" in output
|
||||
assert "item" in output
|
||||
assert "Title" in output
|
||||
assert "code" in output
|
||||
assert "***" not in output
|
||||
assert "~~" not in output
|
||||
assert "`" not in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_lists():
|
||||
renderable = _render_final_assistant_content(
|
||||
"**Formatting**\n- Ran prettier\n- Files changed\n- Verified clean",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "- Ran prettier" in output
|
||||
assert "- Files changed" in output
|
||||
assert "- Verified clean" in output
|
||||
assert "**" not in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_ordered_lists():
|
||||
renderable = _render_final_assistant_content(
|
||||
"1. First item\n2. Second item\n3. Third item",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "1. First" in output
|
||||
assert "2. Second" in output
|
||||
assert "3. Third" in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_blockquotes():
|
||||
renderable = _render_final_assistant_content(
|
||||
"> This is quoted text\n> Another quoted line",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "> This is quoted" in output
|
||||
assert "> Another quoted" in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_checkboxes():
|
||||
renderable = _render_final_assistant_content(
|
||||
"- [ ] Todo item\n- [x] Done item",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "- [ ] Todo" in output
|
||||
assert "- [x] Done" in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_table_structure_while_cleaning_cell_markdown():
|
||||
renderable = _render_final_assistant_content(
|
||||
"| Syntax | Example |\n|---|---|\n| Bold | `**bold**` |\n| Strike | `~~strike~~` |",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "| Syntax | Example |" in output
|
||||
assert "|---|---|" in output
|
||||
assert "| Bold | bold |" in output
|
||||
assert "| Strike | strike |" in output
|
||||
assert "**" not in output
|
||||
assert "~~" not in output
|
||||
assert "`" not in output
|
||||
|
||||
|
||||
def test_final_assistant_content_can_leave_markdown_raw():
|
||||
renderable = _render_final_assistant_content("***Bold italic***", mode="raw")
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "***Bold italic***" in output
|
||||
@@ -207,48 +207,11 @@ def test_cli_turn_routing_uses_primary_when_disabled(monkeypatch):
|
||||
shell.api_mode = "chat_completions"
|
||||
shell.base_url = "https://openrouter.ai/api/v1"
|
||||
shell.api_key = "sk-primary"
|
||||
shell._smart_model_routing = {"enabled": False}
|
||||
|
||||
result = shell._resolve_turn_agent_config("what time is it in tokyo?")
|
||||
|
||||
assert result["model"] == "gpt-5"
|
||||
assert result["runtime"]["provider"] == "openrouter"
|
||||
assert result["label"] is None
|
||||
|
||||
|
||||
def test_cli_turn_routing_uses_cheap_model_when_simple(monkeypatch):
|
||||
cli = _import_cli()
|
||||
|
||||
def _runtime_resolve(**kwargs):
|
||||
assert kwargs["requested"] == "zai"
|
||||
return {
|
||||
"provider": "zai",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "https://open.z.ai/api/v1",
|
||||
"api_key": "cheap-key",
|
||||
"source": "env/config",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
|
||||
|
||||
shell = cli.HermesCLI(model="anthropic/claude-sonnet-4", compact=True, max_turns=1)
|
||||
shell.provider = "openrouter"
|
||||
shell.api_mode = "chat_completions"
|
||||
shell.base_url = "https://openrouter.ai/api/v1"
|
||||
shell.api_key = "primary-key"
|
||||
shell._smart_model_routing = {
|
||||
"enabled": True,
|
||||
"cheap_model": {"provider": "zai", "model": "glm-5-air"},
|
||||
"max_simple_chars": 160,
|
||||
"max_simple_words": 28,
|
||||
}
|
||||
|
||||
result = shell._resolve_turn_agent_config("what time is it in tokyo?")
|
||||
|
||||
assert result["model"] == "glm-5-air"
|
||||
assert result["runtime"]["provider"] == "zai"
|
||||
assert result["runtime"]["api_key"] == "cheap-key"
|
||||
assert result["label"] is not None
|
||||
|
||||
|
||||
def test_cli_prefers_config_provider_over_stale_env_override(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
_cli_mod = None
|
||||
|
||||
|
||||
def _make_cli(user_message_preview=None):
|
||||
global _cli_mod
|
||||
clean_config = {
|
||||
"model": {
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "auto",
|
||||
},
|
||||
"display": {
|
||||
"compact": False,
|
||||
"tool_progress": "all",
|
||||
"user_message_preview": user_message_preview or {"first_lines": 2, "last_lines": 2},
|
||||
},
|
||||
"agent": {},
|
||||
"terminal": {"env_type": "local"},
|
||||
}
|
||||
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
|
||||
prompt_toolkit_stubs = {
|
||||
"prompt_toolkit": MagicMock(),
|
||||
"prompt_toolkit.history": MagicMock(),
|
||||
"prompt_toolkit.styles": MagicMock(),
|
||||
"prompt_toolkit.patch_stdout": MagicMock(),
|
||||
"prompt_toolkit.application": MagicMock(),
|
||||
"prompt_toolkit.layout": MagicMock(),
|
||||
"prompt_toolkit.layout.processors": MagicMock(),
|
||||
"prompt_toolkit.filters": MagicMock(),
|
||||
"prompt_toolkit.layout.dimension": MagicMock(),
|
||||
"prompt_toolkit.layout.menus": MagicMock(),
|
||||
"prompt_toolkit.widgets": MagicMock(),
|
||||
"prompt_toolkit.key_binding": MagicMock(),
|
||||
"prompt_toolkit.completion": MagicMock(),
|
||||
"prompt_toolkit.formatted_text": MagicMock(),
|
||||
"prompt_toolkit.auto_suggest": MagicMock(),
|
||||
}
|
||||
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict("os.environ", clean_env, clear=False):
|
||||
import cli as mod
|
||||
|
||||
mod = importlib.reload(mod)
|
||||
_cli_mod = mod
|
||||
with patch.object(mod, "get_tool_definitions", return_value=[]), patch.dict(mod.__dict__, {"CLI_CONFIG": clean_config}):
|
||||
return mod.HermesCLI()
|
||||
|
||||
|
||||
class TestSubmittedUserMessagePreview:
|
||||
def test_default_preview_shows_first_two_lines_and_last_two_lines(self):
|
||||
cli = _make_cli()
|
||||
|
||||
rendered = cli._format_submitted_user_message_preview(
|
||||
"line1\nline2\nline3\nline4\nline5\nline6"
|
||||
)
|
||||
|
||||
assert "line1" in rendered
|
||||
assert "line2" in rendered
|
||||
assert "line5" in rendered
|
||||
assert "line6" in rendered
|
||||
assert "line3" not in rendered
|
||||
assert "line4" not in rendered
|
||||
assert "(+2 more lines)" in rendered
|
||||
|
||||
def test_preview_can_hide_last_lines(self):
|
||||
cli = _make_cli({"first_lines": 2, "last_lines": 0})
|
||||
|
||||
rendered = cli._format_submitted_user_message_preview(
|
||||
"line1\nline2\nline3\nline4\nline5\nline6"
|
||||
)
|
||||
|
||||
assert "line1" in rendered
|
||||
assert "line2" in rendered
|
||||
assert "line5" not in rendered
|
||||
assert "line6" not in rendered
|
||||
assert "(+4 more lines)" in rendered
|
||||
|
||||
def test_invalid_first_lines_value_falls_back_to_one(self):
|
||||
cli = _make_cli({"first_lines": 0, "last_lines": 2})
|
||||
|
||||
rendered = cli._format_submitted_user_message_preview("line1\nline2\nline3\nline4")
|
||||
|
||||
assert "line1" in rendered
|
||||
assert "line3" in rendered
|
||||
assert "line4" in rendered
|
||||
assert "(+1 more line)" in rendered
|
||||
@@ -183,27 +183,10 @@ class TestFastModeRouting(unittest.TestCase):
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
_smart_model_routing={},
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
original_runtime = {
|
||||
"api_key": "***",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "gpt-5.4",
|
||||
"runtime": dict(original_runtime),
|
||||
"label": None,
|
||||
"signature": ("gpt-5.4", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}):
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
# Provider should NOT have changed
|
||||
assert route["runtime"]["provider"] == "openrouter"
|
||||
@@ -222,26 +205,10 @@ class TestFastModeRouting(unittest.TestCase):
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
_smart_model_routing={},
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
primary_route = {
|
||||
"model": "gpt-5.3-codex",
|
||||
"runtime": {
|
||||
"api_key": "***",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
},
|
||||
"label": None,
|
||||
"signature": ("gpt-5.3-codex", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value=primary_route):
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
assert route["runtime"]["provider"] == "openrouter"
|
||||
assert route.get("request_overrides") is None
|
||||
@@ -329,27 +296,10 @@ class TestAnthropicFastMode(unittest.TestCase):
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
_smart_model_routing={},
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
original_runtime = {
|
||||
"api_key": "***",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
"provider": "anthropic",
|
||||
"api_mode": "anthropic_messages",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "claude-opus-4-6",
|
||||
"runtime": dict(original_runtime),
|
||||
"label": None,
|
||||
"signature": ("claude-opus-4-6", "anthropic", "https://api.anthropic.com", "anthropic_messages", None, ()),
|
||||
}):
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
assert route["runtime"]["provider"] == "anthropic"
|
||||
assert route["request_overrides"] == {"speed": "fast"}
|
||||
|
||||
@@ -21,6 +21,7 @@ def test_manual_compress_reports_noop_without_success_banner(capsys):
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.session_id = shell.session_id # no-op compression: no split
|
||||
shell.agent._compress_context.return_value = (list(history), "")
|
||||
|
||||
def _estimate(messages):
|
||||
@@ -48,6 +49,7 @@ def test_manual_compress_explains_when_token_estimate_rises(capsys):
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.session_id = shell.session_id # no-op: no split
|
||||
shell.agent._compress_context.return_value = (compressed, "")
|
||||
|
||||
def _estimate(messages):
|
||||
@@ -64,3 +66,64 @@ def test_manual_compress_explains_when_token_estimate_rises(capsys):
|
||||
assert "✅ Compressed: 4 → 3 messages" in output
|
||||
assert "Rough transcript estimate: ~100 → ~120 tokens" in output
|
||||
assert "denser summaries" in output
|
||||
|
||||
|
||||
def test_manual_compress_syncs_session_id_after_split():
|
||||
"""Regression for cli.session_id desync after /compress.
|
||||
|
||||
_compress_context ends the parent session and creates a new child session,
|
||||
mutating agent.session_id. Without syncing, cli.session_id still points
|
||||
at the ended parent — causing /status, /resume, exit summary, and the
|
||||
next end_session() call (e.g. from /resume <id>) to target the wrong row.
|
||||
"""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
old_id = shell.session_id
|
||||
new_child_id = "20260101_000000_child1"
|
||||
|
||||
compressed = [
|
||||
{"role": "user", "content": "[summary]"},
|
||||
history[-1],
|
||||
]
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
# Simulate _compress_context mutating agent.session_id as a side effect.
|
||||
def _fake_compress(*args, **kwargs):
|
||||
shell.agent.session_id = new_child_id
|
||||
return (compressed, "")
|
||||
shell.agent._compress_context.side_effect = _fake_compress
|
||||
shell.agent.session_id = old_id # starts in sync
|
||||
shell._pending_title = "stale title"
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress()
|
||||
|
||||
# CLI session_id must now point at the continuation child, not the parent.
|
||||
assert shell.session_id == new_child_id
|
||||
assert shell.session_id != old_id
|
||||
# Pending title must be cleared — titles belong to the parent lineage and
|
||||
# get regenerated for the continuation.
|
||||
assert shell._pending_title is None
|
||||
|
||||
|
||||
def test_manual_compress_no_sync_when_session_id_unchanged():
|
||||
"""If compression is a no-op (agent.session_id didn't change), the CLI
|
||||
must NOT clear _pending_title or otherwise disturb session state.
|
||||
"""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.session_id = shell.session_id
|
||||
shell.agent._compress_context.return_value = (list(history), "")
|
||||
shell._pending_title = "keep me"
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress()
|
||||
|
||||
# No split → pending title untouched.
|
||||
assert shell._pending_title == "keep me"
|
||||
|
||||
@@ -152,7 +152,6 @@ def test_gateway_run_agent_codex_path_handles_internal_401_refresh(monkeypatch):
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._running_agents = {}
|
||||
runner._smart_model_routing = {}
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
runner.hooks = MagicMock()
|
||||
runner.hooks.emit = AsyncMock()
|
||||
|
||||
@@ -1239,6 +1239,30 @@ class TestParseWakeGate:
|
||||
class TestRunJobWakeGate:
|
||||
"""Integration tests for run_job wake-gate short-circuit."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_runtime_provider(self):
|
||||
"""Stub ``resolve_runtime_provider`` for wake-gate tests.
|
||||
|
||||
``run_job`` resolves the runtime provider BEFORE constructing
|
||||
``AIAgent``, so these tests must mock ``resolve_runtime_provider``
|
||||
in addition to ``AIAgent`` — otherwise in a hermetic CI env (no
|
||||
API keys), the resolver raises and the test fails before the
|
||||
patched AIAgent is ever reached.
|
||||
"""
|
||||
fake_runtime = {
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key": "test-key",
|
||||
"source": "stub",
|
||||
"requested_provider": None,
|
||||
}
|
||||
with patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=fake_runtime,
|
||||
):
|
||||
yield
|
||||
|
||||
def _make_job(self, name="wake-gate-test", script="check.py"):
|
||||
"""Minimal valid cron job dict for run_job."""
|
||||
return {
|
||||
|
||||
@@ -75,7 +75,6 @@ def _make_runner():
|
||||
runner._service_tier = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._smart_model_routing = {}
|
||||
runner._running_agents = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._session_db = None
|
||||
|
||||
@@ -283,6 +283,48 @@ def test_persist_dm_topic_thread_id_skips_if_already_set(tmp_path):
|
||||
# ── _get_dm_topic_info ──
|
||||
|
||||
|
||||
def test_persist_dm_topic_thread_id_preserves_config_on_write_failure(tmp_path):
|
||||
"""Failed writes should leave the original config.yaml intact."""
|
||||
import yaml
|
||||
|
||||
config_data = {
|
||||
"platforms": {
|
||||
"telegram": {
|
||||
"extra": {
|
||||
"dm_topics": [
|
||||
{
|
||||
"chat_id": 111,
|
||||
"topics": [
|
||||
{"name": "General", "icon_color": 123},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config_file = tmp_path / ".hermes" / "config.yaml"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
original_text = yaml.dump(config_data)
|
||||
config_file.write_text(original_text, encoding="utf-8")
|
||||
|
||||
adapter = _make_adapter()
|
||||
|
||||
def fail_dump(*args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with patch.object(Path, "home", return_value=tmp_path), \
|
||||
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}), \
|
||||
patch("yaml.dump", side_effect=fail_dump):
|
||||
adapter._persist_dm_topic_thread_id(111, "General", 999)
|
||||
|
||||
assert config_file.read_text(encoding="utf-8") == original_text
|
||||
result = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"]
|
||||
assert "thread_id" not in topics[0]
|
||||
|
||||
|
||||
def test_get_dm_topic_info_finds_cached_topic():
|
||||
"""Should return topic config when thread_id is in cache."""
|
||||
adapter = _make_adapter([
|
||||
|
||||
@@ -4,7 +4,7 @@ import sys
|
||||
import threading
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
@@ -53,7 +53,6 @@ def _make_runner():
|
||||
runner._service_tier = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._smart_model_routing = {}
|
||||
runner._running_agents = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._session_db = None
|
||||
@@ -97,13 +96,7 @@ def test_turn_route_injects_priority_processing_without_changing_runtime():
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "gpt-5.4",
|
||||
"runtime": dict(runtime_kwargs),
|
||||
"label": None,
|
||||
"signature": ("gpt-5.4", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}):
|
||||
route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.4", runtime_kwargs)
|
||||
route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.4", runtime_kwargs)
|
||||
|
||||
assert route["runtime"]["provider"] == "openrouter"
|
||||
assert route["runtime"]["api_mode"] == "chat_completions"
|
||||
@@ -123,13 +116,7 @@ def test_turn_route_skips_priority_processing_for_unsupported_models():
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "gpt-5.3-codex",
|
||||
"runtime": dict(runtime_kwargs),
|
||||
"label": None,
|
||||
"signature": ("gpt-5.3-codex", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}):
|
||||
route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.3-codex", runtime_kwargs)
|
||||
route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.3-codex", runtime_kwargs)
|
||||
|
||||
assert route["request_overrides"] is None
|
||||
|
||||
|
||||
+266
-65
@@ -10,6 +10,8 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from gateway.platforms.base import ProcessingOutcome
|
||||
|
||||
try:
|
||||
import lark_oapi
|
||||
_HAS_LARK_OAPI = True
|
||||
@@ -638,83 +640,54 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
def test_add_ack_reaction_uses_ok_emoji(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
captured = {}
|
||||
|
||||
class _ReactionAPI:
|
||||
def create(self, request):
|
||||
captured["request"] = request
|
||||
return SimpleNamespace(
|
||||
success=lambda: True,
|
||||
data=SimpleNamespace(reaction_id="r_typing"),
|
||||
)
|
||||
|
||||
adapter._client = SimpleNamespace(
|
||||
im=SimpleNamespace(v1=SimpleNamespace(message_reaction=_ReactionAPI()))
|
||||
)
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
|
||||
reaction_id = asyncio.run(adapter._add_ack_reaction("om_msg"))
|
||||
|
||||
self.assertEqual(reaction_id, "r_typing")
|
||||
self.assertEqual(captured["request"].request_body.reaction_type["emoji_type"], "OK")
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_add_ack_reaction_logs_warning_on_failure(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
|
||||
class _ReactionAPI:
|
||||
def create(self, request):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
adapter._client = SimpleNamespace(
|
||||
im=SimpleNamespace(v1=SimpleNamespace(message_reaction=_ReactionAPI()))
|
||||
)
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with (
|
||||
patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct),
|
||||
self.assertLogs("gateway.platforms.feishu", level="WARNING") as logs,
|
||||
):
|
||||
reaction_id = asyncio.run(adapter._add_ack_reaction("om_msg"))
|
||||
|
||||
self.assertIsNone(reaction_id)
|
||||
self.assertTrue(
|
||||
any("Failed to add ack reaction to om_msg" in entry for entry in logs.output),
|
||||
logs.output,
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_ack_reaction_events_are_ignored_to_avoid_feedback_loops(self):
|
||||
def test_bot_origin_reactions_are_dropped_to_avoid_feedback_loops(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
adapter._loop = object()
|
||||
|
||||
for emoji in ("Typing", "CrossMark"):
|
||||
event = SimpleNamespace(
|
||||
message_id="om_msg",
|
||||
operator_type="bot",
|
||||
reaction_type=SimpleNamespace(emoji_type=emoji),
|
||||
)
|
||||
data = SimpleNamespace(event=event)
|
||||
with patch(
|
||||
"gateway.platforms.feishu.asyncio.run_coroutine_threadsafe"
|
||||
) as run_threadsafe:
|
||||
adapter._on_reaction_event("im.message.reaction.created_v1", data)
|
||||
run_threadsafe.assert_not_called()
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_user_reaction_with_managed_emoji_is_still_routed(self):
|
||||
# Operator-origin filter is enough to prevent feedback loops; we must
|
||||
# not additionally swallow user-origin reactions just because their
|
||||
# emoji happens to collide with a lifecycle emoji.
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
adapter._loop = SimpleNamespace(is_closed=lambda: False)
|
||||
|
||||
event = SimpleNamespace(
|
||||
message_id="om_msg",
|
||||
operator_type="user",
|
||||
reaction_type=SimpleNamespace(emoji_type="OK"),
|
||||
reaction_type=SimpleNamespace(emoji_type="Typing"),
|
||||
)
|
||||
data = SimpleNamespace(event=event)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.run_coroutine_threadsafe") as run_threadsafe:
|
||||
adapter._on_reaction_event("im.message.reaction.created_v1", data)
|
||||
def _close_coro_and_return_future(coro, _loop):
|
||||
coro.close()
|
||||
return SimpleNamespace(add_done_callback=lambda _: None)
|
||||
|
||||
run_threadsafe.assert_not_called()
|
||||
with patch(
|
||||
"gateway.platforms.feishu.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=_close_coro_and_return_future,
|
||||
) as run_threadsafe:
|
||||
adapter._on_reaction_event("im.message.reaction.created_v1", data)
|
||||
run_threadsafe.assert_called_once()
|
||||
|
||||
@patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
|
||||
def test_group_message_requires_mentions_even_when_policy_open(self):
|
||||
@@ -3278,3 +3251,231 @@ class TestSenderNameResolution(unittest.TestCase):
|
||||
result = asyncio.run(adapter._resolve_sender_name_from_api("ou_broken"))
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestProcessingReactions(unittest.TestCase):
|
||||
"""Typing on start → removed on SUCCESS, swapped for CrossMark on FAILURE,
|
||||
removed (no replacement) on CANCELLED."""
|
||||
|
||||
@staticmethod
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
def _build_adapter(
|
||||
self,
|
||||
create_success: bool = True,
|
||||
delete_success: bool = True,
|
||||
next_reaction_id: str = "r1",
|
||||
):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
tracker = SimpleNamespace(
|
||||
create_calls=[],
|
||||
delete_calls=[],
|
||||
next_reaction_id=next_reaction_id,
|
||||
create_success=create_success,
|
||||
delete_success=delete_success,
|
||||
)
|
||||
|
||||
def _create(request):
|
||||
tracker.create_calls.append(
|
||||
request.request_body.reaction_type["emoji_type"]
|
||||
)
|
||||
if tracker.create_success:
|
||||
return SimpleNamespace(
|
||||
success=lambda: True,
|
||||
data=SimpleNamespace(reaction_id=tracker.next_reaction_id),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
success=lambda: False, code=99, msg="rejected", data=None,
|
||||
)
|
||||
|
||||
def _delete(request):
|
||||
tracker.delete_calls.append(request.reaction_id)
|
||||
return SimpleNamespace(
|
||||
success=lambda: tracker.delete_success,
|
||||
code=0 if tracker.delete_success else 99,
|
||||
msg="success" if tracker.delete_success else "rejected",
|
||||
)
|
||||
|
||||
adapter._client = SimpleNamespace(
|
||||
im=SimpleNamespace(
|
||||
v1=SimpleNamespace(
|
||||
message_reaction=SimpleNamespace(create=_create, delete=_delete),
|
||||
),
|
||||
),
|
||||
)
|
||||
return adapter, tracker
|
||||
|
||||
@staticmethod
|
||||
def _event(message_id: str = "om_msg"):
|
||||
return SimpleNamespace(message_id=message_id)
|
||||
|
||||
def _patch_to_thread(self):
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct)
|
||||
|
||||
# ------------------------------------------------------------------ start
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_start_adds_typing_and_caches_reaction_id(self):
|
||||
adapter, tracker = self._build_adapter(next_reaction_id="r_typing")
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self.assertEqual(tracker.create_calls, ["Typing"])
|
||||
self.assertEqual(adapter._pending_processing_reactions["om_msg"], "r_typing")
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_start_is_idempotent_for_same_message_id(self):
|
||||
adapter, tracker = self._build_adapter(next_reaction_id="r_typing")
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self.assertEqual(tracker.create_calls, ["Typing"])
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_start_does_not_cache_when_create_fails(self):
|
||||
adapter, tracker = self._build_adapter(create_success=False)
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self.assertEqual(tracker.create_calls, ["Typing"])
|
||||
self.assertNotIn("om_msg", adapter._pending_processing_reactions)
|
||||
|
||||
# --------------------------------------------------------------- complete
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_success_removes_typing_and_adds_nothing(self):
|
||||
adapter, tracker = self._build_adapter(next_reaction_id="r_typing")
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.SUCCESS)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, ["Typing"])
|
||||
self.assertEqual(tracker.delete_calls, ["r_typing"])
|
||||
self.assertNotIn("om_msg", adapter._pending_processing_reactions)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_failure_removes_typing_then_adds_cross_mark(self):
|
||||
adapter, tracker = self._build_adapter(next_reaction_id="r_typing")
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.FAILURE)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, ["Typing", "CrossMark"])
|
||||
self.assertEqual(tracker.delete_calls, ["r_typing"])
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_cancelled_removes_typing_and_adds_nothing(self):
|
||||
adapter, tracker = self._build_adapter(next_reaction_id="r_typing")
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.CANCELLED)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, ["Typing"])
|
||||
self.assertEqual(tracker.delete_calls, ["r_typing"])
|
||||
self.assertNotIn("om_msg", adapter._pending_processing_reactions)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_failure_without_preceding_start_still_adds_cross_mark(self):
|
||||
adapter, tracker = self._build_adapter()
|
||||
with self._patch_to_thread():
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.FAILURE)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, ["CrossMark"])
|
||||
self.assertEqual(tracker.delete_calls, [])
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_success_without_preceding_start_is_full_noop(self):
|
||||
adapter, tracker = self._build_adapter()
|
||||
with self._patch_to_thread():
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.SUCCESS)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, [])
|
||||
self.assertEqual(tracker.delete_calls, [])
|
||||
|
||||
# ------------------------- delete failure: don't stack badges -----------
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_delete_failure_on_failure_outcome_skips_cross_mark(self):
|
||||
# Removing Typing is best-effort — but if it fails, we must NOT
|
||||
# additionally add CrossMark, or the UI would show two contradictory
|
||||
# badges. The handle stays in the cache for LRU to clean up later.
|
||||
adapter, tracker = self._build_adapter(
|
||||
next_reaction_id="r_typing", delete_success=False,
|
||||
)
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.FAILURE)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, ["Typing"]) # CrossMark NOT added
|
||||
self.assertEqual(tracker.delete_calls, ["r_typing"]) # delete was attempted
|
||||
self.assertEqual(
|
||||
adapter._pending_processing_reactions["om_msg"], "r_typing",
|
||||
) # handle retained
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_delete_failure_on_success_outcome_retains_handle(self):
|
||||
adapter, tracker = self._build_adapter(
|
||||
next_reaction_id="r_typing", delete_success=False,
|
||||
)
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.SUCCESS)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, ["Typing"])
|
||||
self.assertEqual(tracker.delete_calls, ["r_typing"])
|
||||
self.assertEqual(
|
||||
adapter._pending_processing_reactions["om_msg"], "r_typing",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------- env toggle
|
||||
@patch.dict(os.environ, {"FEISHU_REACTIONS": "false"}, clear=True)
|
||||
def test_env_disable_short_circuits_both_hooks(self):
|
||||
adapter, tracker = self._build_adapter()
|
||||
with self._patch_to_thread():
|
||||
self._run(adapter.on_processing_start(self._event()))
|
||||
self._run(
|
||||
adapter.on_processing_complete(self._event(), ProcessingOutcome.FAILURE)
|
||||
)
|
||||
self.assertEqual(tracker.create_calls, [])
|
||||
self.assertEqual(tracker.delete_calls, [])
|
||||
|
||||
# ------------------------------------------------------------- LRU bounds
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_cache_evicts_oldest_entry_beyond_size_limit(self):
|
||||
from gateway.platforms.feishu import _FEISHU_PROCESSING_REACTION_CACHE_SIZE
|
||||
|
||||
adapter, _ = self._build_adapter()
|
||||
counter = {"n": 0}
|
||||
|
||||
def _create(_request):
|
||||
counter["n"] += 1
|
||||
return SimpleNamespace(
|
||||
success=lambda: True,
|
||||
data=SimpleNamespace(reaction_id=f"r{counter['n']}"),
|
||||
)
|
||||
|
||||
adapter._client.im.v1.message_reaction.create = _create
|
||||
|
||||
with self._patch_to_thread():
|
||||
for i in range(_FEISHU_PROCESSING_REACTION_CACHE_SIZE + 1):
|
||||
self._run(adapter.on_processing_start(self._event(f"om_{i}")))
|
||||
|
||||
self.assertNotIn("om_0", adapter._pending_processing_reactions)
|
||||
self.assertIn(
|
||||
f"om_{_FEISHU_PROCESSING_REACTION_CACHE_SIZE}",
|
||||
adapter._pending_processing_reactions,
|
||||
)
|
||||
self.assertEqual(
|
||||
len(adapter._pending_processing_reactions),
|
||||
_FEISHU_PROCESSING_REACTION_CACHE_SIZE,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Regression tests: /yolo and /verbose dispatch mid-agent-run.
|
||||
|
||||
When an agent is running, the gateway's running-agent guard rejects most
|
||||
slash commands with "⏳ Agent is running — /{cmd} can't run mid-turn"
|
||||
(PR #12334). A small allowlist bypasses that and actually dispatches:
|
||||
|
||||
* /yolo — toggles the session yolo flag; useful to pre-approve a
|
||||
pending approval prompt without waiting for the agent to finish.
|
||||
* /verbose — cycles the per-platform tool-progress display mode;
|
||||
affects the ongoing stream.
|
||||
|
||||
Commands whose handlers say "takes effect on next message" stay on the
|
||||
catch-all by design:
|
||||
|
||||
* /fast — writes config.yaml only
|
||||
* /reasoning — writes config.yaml only
|
||||
|
||||
These tests lock in both behaviors so the allowlist doesn't silently
|
||||
grow or shrink.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||
|
||||
|
||||
def _make_source() -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
user_id="u1",
|
||||
chat_id="c1",
|
||||
user_name="tester",
|
||||
chat_type="dm",
|
||||
)
|
||||
|
||||
|
||||
def _make_event(text: str) -> MessageEvent:
|
||||
return MessageEvent(text=text, source=_make_source(), message_id="m1")
|
||||
|
||||
|
||||
def _make_runner():
|
||||
"""Minimal GatewayRunner with an active running agent for this session."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
|
||||
)
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||||
runner._voice_mode = {}
|
||||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||||
|
||||
session_entry = SessionEntry(
|
||||
session_key=build_session_key(_make_source()),
|
||||
session_id="sess-1",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
)
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store.get_or_create_session.return_value = session_entry
|
||||
runner.session_store.load_transcript.return_value = []
|
||||
runner.session_store.has_any_sessions.return_value = True
|
||||
runner.session_store.append_to_transcript = MagicMock()
|
||||
runner.session_store.rewrite_transcript = MagicMock()
|
||||
runner.session_store.update_session = MagicMock()
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._session_db = None
|
||||
runner._reasoning_config = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._show_reasoning = False
|
||||
runner._service_tier = None
|
||||
runner._is_user_authorized = lambda _source: True
|
||||
runner._set_session_env = lambda _context: None
|
||||
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
|
||||
runner._send_voice_reply = AsyncMock()
|
||||
runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None
|
||||
runner._emit_gateway_run_progress = AsyncMock()
|
||||
|
||||
# Simulate agent actively running for this session so the guard fires.
|
||||
# Note: the stale-eviction branch calls agent.get_activity_summary() and
|
||||
# compares seconds_since_activity against HERMES_AGENT_TIMEOUT. Return a
|
||||
# dict with recent activity so the eviction path doesn't clear our
|
||||
# fake running agent before the toggle guard runs.
|
||||
import time
|
||||
sk = build_session_key(_make_source())
|
||||
agent_mock = MagicMock()
|
||||
agent_mock.get_activity_summary.return_value = {
|
||||
"seconds_since_activity": 0.0,
|
||||
"last_activity_desc": "api_call",
|
||||
"api_call_count": 1,
|
||||
"max_iterations": 60,
|
||||
}
|
||||
runner._running_agents[sk] = agent_mock
|
||||
runner._running_agents_ts[sk] = time.time()
|
||||
return runner
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yolo_dispatches_mid_run(monkeypatch):
|
||||
"""/yolo mid-run must dispatch to its handler, not hit the catch-all."""
|
||||
runner = _make_runner()
|
||||
runner._handle_yolo_command = AsyncMock(return_value="⚡ YOLO mode **ON** for this session")
|
||||
|
||||
result = await runner._handle_message(_make_event("/yolo"))
|
||||
|
||||
runner._handle_yolo_command.assert_awaited_once()
|
||||
assert result == "⚡ YOLO mode **ON** for this session"
|
||||
assert "can't run mid-turn" not in (result or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verbose_dispatches_mid_run(monkeypatch):
|
||||
"""/verbose mid-run must dispatch to its handler, not hit the catch-all."""
|
||||
runner = _make_runner()
|
||||
runner._handle_verbose_command = AsyncMock(return_value="tool progress: new")
|
||||
|
||||
result = await runner._handle_message(_make_event("/verbose"))
|
||||
|
||||
runner._handle_verbose_command.assert_awaited_once()
|
||||
assert result == "tool progress: new"
|
||||
assert "can't run mid-turn" not in (result or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fast_rejected_mid_run():
|
||||
"""/fast mid-run must hit the busy catch-all — config-only, next message."""
|
||||
runner = _make_runner()
|
||||
runner._handle_fast_command = AsyncMock(
|
||||
side_effect=AssertionError("/fast should not dispatch mid-run")
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("/fast"))
|
||||
|
||||
runner._handle_fast_command.assert_not_awaited()
|
||||
assert result is not None
|
||||
assert "can't run mid-turn" in result
|
||||
assert "/fast" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_rejected_mid_run():
|
||||
"""/reasoning mid-run must hit the busy catch-all — config-only, next message."""
|
||||
runner = _make_runner()
|
||||
runner._handle_reasoning_command = AsyncMock(
|
||||
side_effect=AssertionError("/reasoning should not dispatch mid-run")
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("/reasoning high"))
|
||||
|
||||
runner._handle_reasoning_command.assert_not_awaited()
|
||||
assert result is not None
|
||||
assert "can't run mid-turn" in result
|
||||
assert "/reasoning" in result
|
||||
@@ -117,11 +117,20 @@ class TestPruneBasics:
|
||||
assert "idle" not in store._entries
|
||||
|
||||
def test_prune_skips_entries_with_active_processes(self, tmp_path):
|
||||
"""Sessions with active bg processes aren't pruned even if old."""
|
||||
active_session_ids = {"sid_active"}
|
||||
"""Sessions with active bg processes aren't pruned even if old.
|
||||
|
||||
def _has_active(session_id: str) -> bool:
|
||||
return session_id in active_session_ids
|
||||
The callback is keyed by session_key — matching what
|
||||
process_registry.has_active_for_session() actually consumes in
|
||||
gateway/run.py. Prior to the fix this test passed the callback a
|
||||
session_id, which silently matched an implementation bug where
|
||||
prune_old_entries was also passing session_id; real-world usage
|
||||
(via process_registry) takes a session_key and never matched, so
|
||||
active sessions were still being pruned.
|
||||
"""
|
||||
active_session_keys = {"active"}
|
||||
|
||||
def _has_active(session_key: str) -> bool:
|
||||
return session_key in active_session_keys
|
||||
|
||||
store = _make_store(tmp_path, has_active_processes_fn=_has_active)
|
||||
store._entries["active"] = _entry(
|
||||
@@ -137,6 +146,26 @@ class TestPruneBasics:
|
||||
assert "active" in store._entries
|
||||
assert "idle" not in store._entries
|
||||
|
||||
def test_prune_active_check_uses_session_key_not_session_id(self, tmp_path):
|
||||
"""Regression guard: a callback that only recognises session_ids must
|
||||
NOT protect entries during prune. This pins the fix so a future
|
||||
refactor can't silently revert to passing session_id again.
|
||||
"""
|
||||
def _recognises_only_ids(identifier: str) -> bool:
|
||||
return identifier.startswith("sid_")
|
||||
|
||||
store = _make_store(tmp_path, has_active_processes_fn=_recognises_only_ids)
|
||||
store._entries["active"] = _entry(
|
||||
"active", age_days=1000, session_id="sid_active"
|
||||
)
|
||||
|
||||
removed = store.prune_old_entries(max_age_days=90)
|
||||
|
||||
# Entry is pruned because the callback receives "active" (session_key),
|
||||
# not "sid_active" (session_id), so _recognises_only_ids returns False.
|
||||
assert removed == 1
|
||||
assert "active" not in store._entries
|
||||
|
||||
def test_prune_does_not_write_disk_when_no_removals(self, tmp_path):
|
||||
"""If nothing is evictable, _save() should NOT be called."""
|
||||
store = _make_store(tmp_path)
|
||||
|
||||
@@ -91,6 +91,29 @@ class TestSignalAdapterInit:
|
||||
assert adapter._account_normalized == "+15551234567"
|
||||
|
||||
|
||||
class TestSignalConnectCleanup:
|
||||
"""Regression coverage for failed connect() cleanup."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_releases_lock_and_closes_client_on_healthcheck_failure(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=MagicMock(status_code=503))
|
||||
mock_client.aclose = AsyncMock()
|
||||
|
||||
with patch("gateway.platforms.signal.httpx.AsyncClient", return_value=mock_client), \
|
||||
patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \
|
||||
patch("gateway.status.release_scoped_lock") as mock_release:
|
||||
result = await adapter.connect()
|
||||
|
||||
assert result is False
|
||||
mock_client.aclose.assert_awaited_once()
|
||||
mock_release.assert_called_once_with("signal-phone", "+15551234567")
|
||||
assert adapter.client is None
|
||||
assert adapter._platform_lock_identity is None
|
||||
|
||||
|
||||
class TestSignalHelpers:
|
||||
def test_redact_phone_long(self):
|
||||
from gateway.platforms.helpers import redact_phone
|
||||
@@ -438,6 +461,97 @@ class TestSignalSendImageFile:
|
||||
assert "failed" in result.error.lower()
|
||||
|
||||
|
||||
class TestSignalRecipientResolution:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_prefers_cached_uuid_for_direct_messages(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
adapter._remember_recipient_identifiers("+15551230000", "68680952-6d86-45bc-85e0-1a4d186d53ee")
|
||||
|
||||
captured = []
|
||||
|
||||
async def mock_rpc(method, params, rpc_id=None, **kwargs):
|
||||
captured.append({"method": method, "params": dict(params)})
|
||||
return {"timestamp": 1234567890}
|
||||
|
||||
adapter._rpc = mock_rpc
|
||||
|
||||
result = await adapter.send(chat_id="+15551230000", content="hello")
|
||||
|
||||
assert result.success is True
|
||||
assert captured[0]["method"] == "send"
|
||||
assert captured[0]["params"]["recipient"] == ["68680952-6d86-45bc-85e0-1a4d186d53ee"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_looks_up_uuid_via_list_contacts(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
captured = []
|
||||
|
||||
async def mock_rpc(method, params, rpc_id=None, **kwargs):
|
||||
captured.append({"method": method, "params": dict(params)})
|
||||
if method == "listContacts":
|
||||
return [{
|
||||
"recipient": "351935789098",
|
||||
"number": "+15551230000",
|
||||
"uuid": "68680952-6d86-45bc-85e0-1a4d186d53ee",
|
||||
"isRegistered": True,
|
||||
}]
|
||||
if method == "send":
|
||||
return {"timestamp": 1234567890}
|
||||
return None
|
||||
|
||||
adapter._rpc = mock_rpc
|
||||
|
||||
result = await adapter.send(chat_id="+15551230000", content="hello")
|
||||
|
||||
assert result.success is True
|
||||
assert captured[0]["method"] == "listContacts"
|
||||
assert captured[1]["method"] == "send"
|
||||
assert captured[1]["params"]["recipient"] == ["68680952-6d86-45bc-85e0-1a4d186d53ee"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_falls_back_to_phone_when_no_uuid_found(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
captured = []
|
||||
|
||||
async def mock_rpc(method, params, rpc_id=None, **kwargs):
|
||||
captured.append({"method": method, "params": dict(params)})
|
||||
if method == "listContacts":
|
||||
return []
|
||||
if method == "send":
|
||||
return {"timestamp": 1234567890}
|
||||
return None
|
||||
|
||||
adapter._rpc = mock_rpc
|
||||
|
||||
result = await adapter.send(chat_id="+15551230000", content="hello")
|
||||
|
||||
assert result.success is True
|
||||
assert captured[1]["params"]["recipient"] == ["+15551230000"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_typing_uses_cached_uuid(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._remember_recipient_identifiers("+15551230000", "68680952-6d86-45bc-85e0-1a4d186d53ee")
|
||||
|
||||
captured = []
|
||||
|
||||
async def mock_rpc(method, params, rpc_id=None, **kwargs):
|
||||
captured.append({"method": method, "params": dict(params), "rpc_id": rpc_id})
|
||||
return {}
|
||||
|
||||
adapter._rpc = mock_rpc
|
||||
|
||||
await adapter.send_typing("+15551230000")
|
||||
|
||||
assert captured[0]["method"] == "sendTyping"
|
||||
assert captured[0]["params"]["recipient"] == ["68680952-6d86-45bc-85e0-1a4d186d53ee"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send_voice method (#5105)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -150,6 +150,31 @@ class TestAppMentionHandler:
|
||||
assert "/hermes" in registered_commands
|
||||
|
||||
|
||||
class TestSlackConnectCleanup:
|
||||
"""Regression coverage for failed connect() cleanup."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_releases_platform_lock_when_auth_fails(self):
|
||||
config = PlatformConfig(enabled=True, token="xoxb-fake")
|
||||
adapter = SlackAdapter(config)
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_web_client = AsyncMock()
|
||||
mock_web_client.auth_test = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \
|
||||
patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \
|
||||
patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \
|
||||
patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \
|
||||
patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \
|
||||
patch("gateway.status.release_scoped_lock") as mock_release:
|
||||
result = await adapter.connect()
|
||||
|
||||
assert result is False
|
||||
mock_release.assert_called_once_with("slack-app-token", "xapp-fake")
|
||||
assert adapter._platform_lock_identity is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSendDocument
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -133,6 +133,43 @@ class TestFinalizeCapabilityGate:
|
||||
assert picky.edit_message.call_args[1]["finalize"] is True
|
||||
|
||||
|
||||
class TestEditMessageFinalizeSignature:
|
||||
"""Every concrete platform adapter must accept the ``finalize`` kwarg.
|
||||
|
||||
stream_consumer._send_or_edit always passes ``finalize=`` to
|
||||
``adapter.edit_message(...)`` (see gateway/stream_consumer.py). An
|
||||
adapter that overrides edit_message without accepting finalize raises
|
||||
TypeError the first time streaming hits a segment break or final edit.
|
||||
Guard the contract with an explicit signature check so it cannot
|
||||
silently regress — existing tests use MagicMock which swallows any
|
||||
kwarg and cannot catch this.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module_path,class_name",
|
||||
[
|
||||
("gateway.platforms.telegram", "TelegramAdapter"),
|
||||
("gateway.platforms.discord", "DiscordAdapter"),
|
||||
("gateway.platforms.slack", "SlackAdapter"),
|
||||
("gateway.platforms.matrix", "MatrixAdapter"),
|
||||
("gateway.platforms.mattermost", "MattermostAdapter"),
|
||||
("gateway.platforms.feishu", "FeishuAdapter"),
|
||||
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
|
||||
("gateway.platforms.dingtalk", "DingTalkAdapter"),
|
||||
],
|
||||
)
|
||||
def test_edit_message_accepts_finalize(self, module_path, class_name):
|
||||
import inspect
|
||||
|
||||
module = pytest.importorskip(module_path)
|
||||
cls = getattr(module, class_name)
|
||||
params = inspect.signature(cls.edit_message).parameters
|
||||
assert "finalize" in params, (
|
||||
f"{class_name}.edit_message must accept 'finalize' kwarg; "
|
||||
f"stream_consumer._send_or_edit passes it unconditionally"
|
||||
)
|
||||
|
||||
|
||||
class TestSendOrEditMediaStripping:
|
||||
"""Verify _send_or_edit strips MEDIA: before sending to the platform."""
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Tests for Telegram bot mention detection (bug #12545).
|
||||
|
||||
The old implementation used a naive substring check
|
||||
(`f"@{bot_username}" in text.lower()`), which incorrectly matched partial
|
||||
substrings like 'foo@hermes_bot.example'.
|
||||
|
||||
Detection now relies entirely on the MessageEntity objects Telegram's server
|
||||
emits for real mentions. A bare `@username` substring in message text without
|
||||
a corresponding `MENTION` entity is NOT a mention — this correctly ignores
|
||||
@handles that appear inside URLs, code blocks, email-like strings, or quoted
|
||||
text, because Telegram's parser does not emit mention entities for any of
|
||||
those contexts.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
adapter = object.__new__(TelegramAdapter)
|
||||
adapter.platform = Platform.TELEGRAM
|
||||
adapter.config = PlatformConfig(enabled=True, token="***", extra={})
|
||||
adapter._bot = SimpleNamespace(id=999, username="hermes_bot")
|
||||
return adapter
|
||||
|
||||
|
||||
def _mention_entity(text, mention="@hermes_bot"):
|
||||
"""Build a MENTION entity pointing at a literal `@username` in `text`."""
|
||||
offset = text.index(mention)
|
||||
return SimpleNamespace(type="mention", offset=offset, length=len(mention))
|
||||
|
||||
|
||||
def _text_mention_entity(offset, length, user_id):
|
||||
"""Build a TEXT_MENTION entity (used when the target user has no public @handle)."""
|
||||
return SimpleNamespace(
|
||||
type="text_mention",
|
||||
offset=offset,
|
||||
length=length,
|
||||
user=SimpleNamespace(id=user_id),
|
||||
)
|
||||
|
||||
|
||||
def _message(text=None, caption=None, entities=None, caption_entities=None):
|
||||
return SimpleNamespace(
|
||||
text=text,
|
||||
caption=caption,
|
||||
entities=entities or [],
|
||||
caption_entities=caption_entities or [],
|
||||
message_thread_id=None,
|
||||
chat=SimpleNamespace(id=-100, type="group"),
|
||||
reply_to_message=None,
|
||||
)
|
||||
|
||||
|
||||
class TestRealMentionsAreDetected:
|
||||
"""A real Telegram mention always comes with a MENTION entity — detect those."""
|
||||
|
||||
def test_mention_at_start_of_message(self):
|
||||
adapter = _make_adapter()
|
||||
text = "@hermes_bot hello world"
|
||||
msg = _message(text=text, entities=[_mention_entity(text)])
|
||||
assert adapter._message_mentions_bot(msg) is True
|
||||
|
||||
def test_mention_mid_sentence(self):
|
||||
adapter = _make_adapter()
|
||||
text = "hey @hermes_bot, can you help?"
|
||||
msg = _message(text=text, entities=[_mention_entity(text)])
|
||||
assert adapter._message_mentions_bot(msg) is True
|
||||
|
||||
def test_mention_at_end_of_message(self):
|
||||
adapter = _make_adapter()
|
||||
text = "thanks for looking @hermes_bot"
|
||||
msg = _message(text=text, entities=[_mention_entity(text)])
|
||||
assert adapter._message_mentions_bot(msg) is True
|
||||
|
||||
def test_mention_in_caption(self):
|
||||
adapter = _make_adapter()
|
||||
caption = "photo for @hermes_bot"
|
||||
msg = _message(caption=caption, caption_entities=[_mention_entity(caption)])
|
||||
assert adapter._message_mentions_bot(msg) is True
|
||||
|
||||
def test_text_mention_entity_targets_bot(self):
|
||||
"""TEXT_MENTION is Telegram's entity type for @FirstName -> user without a public handle."""
|
||||
adapter = _make_adapter()
|
||||
msg = _message(text="hey you", entities=[_text_mention_entity(4, 3, user_id=999)])
|
||||
assert adapter._message_mentions_bot(msg) is True
|
||||
|
||||
|
||||
class TestSubstringFalsePositivesAreRejected:
|
||||
"""Bare `@bot_username` substrings without a MENTION entity must NOT match.
|
||||
|
||||
These are all inputs where the OLD substring check returned True incorrectly.
|
||||
A word-boundary regex would still over-match some of these (code blocks,
|
||||
URLs). Entity-based detection handles them all correctly because Telegram's
|
||||
parser does not emit mention entities for non-mention contexts.
|
||||
"""
|
||||
|
||||
def test_email_like_substring(self):
|
||||
"""bug #12545 exact repro: 'foo@hermes_bot.example'."""
|
||||
adapter = _make_adapter()
|
||||
msg = _message(text="email me at foo@hermes_bot.example")
|
||||
assert adapter._message_mentions_bot(msg) is False
|
||||
|
||||
def test_hostname_substring(self):
|
||||
adapter = _make_adapter()
|
||||
msg = _message(text="contact user@hermes_bot.domain.com")
|
||||
assert adapter._message_mentions_bot(msg) is False
|
||||
|
||||
def test_superstring_username(self):
|
||||
"""`@hermes_botx` is a different username; Telegram would emit a mention
|
||||
entity for `@hermes_botx`, not `@hermes_bot`."""
|
||||
adapter = _make_adapter()
|
||||
msg = _message(text="@hermes_botx hello")
|
||||
assert adapter._message_mentions_bot(msg) is False
|
||||
|
||||
def test_underscore_suffix_substring(self):
|
||||
adapter = _make_adapter()
|
||||
msg = _message(text="see @hermes_bot_admin for help")
|
||||
assert adapter._message_mentions_bot(msg) is False
|
||||
|
||||
def test_substring_inside_url_without_entity(self):
|
||||
"""@handle inside a URL produces a URL entity, not a MENTION entity."""
|
||||
adapter = _make_adapter()
|
||||
msg = _message(text="see https://example.com/@hermes_bot for details")
|
||||
assert adapter._message_mentions_bot(msg) is False
|
||||
|
||||
def test_substring_inside_code_block_without_entity(self):
|
||||
"""Telegram doesn't emit mention entities inside code/pre entities."""
|
||||
adapter = _make_adapter()
|
||||
msg = _message(text="use the string `@hermes_bot` in config")
|
||||
assert adapter._message_mentions_bot(msg) is False
|
||||
|
||||
def test_plain_text_with_no_at_sign(self):
|
||||
adapter = _make_adapter()
|
||||
msg = _message(text="just a normal group message")
|
||||
assert adapter._message_mentions_bot(msg) is False
|
||||
|
||||
def test_email_substring_in_caption(self):
|
||||
adapter = _make_adapter()
|
||||
msg = _message(caption="foo@hermes_bot.example")
|
||||
assert adapter._message_mentions_bot(msg) is False
|
||||
|
||||
|
||||
class TestEntityEdgeCases:
|
||||
"""Malformed or mismatched entities should not crash or over-match."""
|
||||
|
||||
def test_mention_entity_for_different_username(self):
|
||||
adapter = _make_adapter()
|
||||
text = "@someone_else hi"
|
||||
msg = _message(text=text, entities=[_mention_entity(text, mention="@someone_else")])
|
||||
assert adapter._message_mentions_bot(msg) is False
|
||||
|
||||
def test_text_mention_entity_for_different_user(self):
|
||||
adapter = _make_adapter()
|
||||
msg = _message(text="hi there", entities=[_text_mention_entity(0, 2, user_id=12345)])
|
||||
assert adapter._message_mentions_bot(msg) is False
|
||||
|
||||
def test_malformed_entity_with_negative_offset(self):
|
||||
adapter = _make_adapter()
|
||||
msg = _message(text="@hermes_bot hi",
|
||||
entities=[SimpleNamespace(type="mention", offset=-1, length=11)])
|
||||
assert adapter._message_mentions_bot(msg) is False
|
||||
|
||||
def test_malformed_entity_with_zero_length(self):
|
||||
adapter = _make_adapter()
|
||||
msg = _message(text="@hermes_bot hi",
|
||||
entities=[SimpleNamespace(type="mention", offset=0, length=0)])
|
||||
assert adapter._message_mentions_bot(msg) is False
|
||||
|
||||
|
||||
class TestCaseInsensitivity:
|
||||
"""Telegram usernames are case-insensitive; the slice-compare normalizes both sides."""
|
||||
|
||||
def test_uppercase_mention(self):
|
||||
adapter = _make_adapter()
|
||||
text = "hi @HERMES_BOT"
|
||||
msg = _message(text=text, entities=[_mention_entity(text, mention="@HERMES_BOT")])
|
||||
assert adapter._message_mentions_bot(msg) is True
|
||||
|
||||
def test_mixed_case_mention(self):
|
||||
adapter = _make_adapter()
|
||||
text = "hi @Hermes_Bot"
|
||||
msg = _message(text=text, entities=[_mention_entity(text, mention="@Hermes_Bot")])
|
||||
assert adapter._message_mentions_bot(msg) is True
|
||||
@@ -63,6 +63,12 @@ def _make_runner(platform: Platform, config: GatewayConfig):
|
||||
runner.pairing_store = MagicMock()
|
||||
runner.pairing_store.is_approved.return_value = False
|
||||
runner.pairing_store._is_rate_limited.return_value = False
|
||||
# Attributes required by _handle_message for the authorized-user path
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._update_prompts = {}
|
||||
runner.hooks = SimpleNamespace(dispatch=AsyncMock(return_value=None))
|
||||
runner._sessions = {}
|
||||
return runner, adapter
|
||||
|
||||
|
||||
@@ -295,3 +301,172 @@ async def test_global_ignore_suppresses_pairing_reply(monkeypatch):
|
||||
assert result is None
|
||||
runner.pairing_store.generate_code.assert_not_called()
|
||||
adapter.send.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist-configured platforms default to "ignore" for unauthorized users
|
||||
# (#9337: Signal gateway sends pairing spam when allowlist is configured)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signal_with_allowlist_ignores_unauthorized_dm(monkeypatch):
|
||||
"""When SIGNAL_ALLOWED_USERS is set, unauthorized DMs are silently dropped.
|
||||
|
||||
This is the primary regression test for #9337: before the fix, Signal
|
||||
would send pairing codes to ANY sender even when a strict allowlist was
|
||||
configured, spamming personal contacts with cryptic bot messages.
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "+15550000001") # allowlist set
|
||||
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.SIGNAL: PlatformConfig(enabled=True)},
|
||||
)
|
||||
runner, adapter = _make_runner(Platform.SIGNAL, config)
|
||||
|
||||
result = await runner._handle_message(
|
||||
_make_event(Platform.SIGNAL, "+15559999999", "+15559999999") # not in allowlist
|
||||
)
|
||||
|
||||
assert result is None
|
||||
runner.pairing_store.generate_code.assert_not_called()
|
||||
adapter.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_with_allowlist_ignores_unauthorized_dm(monkeypatch):
|
||||
"""Same behavior for Telegram: allowlist ⟹ ignore unauthorized DMs."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "111111111")
|
||||
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True)},
|
||||
)
|
||||
runner, adapter = _make_runner(Platform.TELEGRAM, config)
|
||||
|
||||
result = await runner._handle_message(
|
||||
_make_event(Platform.TELEGRAM, "999999999", "999999999")
|
||||
)
|
||||
|
||||
assert result is None
|
||||
runner.pairing_store.generate_code.assert_not_called()
|
||||
adapter.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_allowlist_ignores_unauthorized_dm(monkeypatch):
|
||||
"""GATEWAY_ALLOWED_USERS also triggers the 'ignore' behavior."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "111111111")
|
||||
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.SIGNAL: PlatformConfig(enabled=True)},
|
||||
)
|
||||
runner, adapter = _make_runner(Platform.SIGNAL, config)
|
||||
|
||||
result = await runner._handle_message(
|
||||
_make_event(Platform.SIGNAL, "+15559999999", "+15559999999")
|
||||
)
|
||||
|
||||
assert result is None
|
||||
runner.pairing_store.generate_code.assert_not_called()
|
||||
adapter.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_allowlist_still_pairs_by_default(monkeypatch):
|
||||
"""Without any allowlist, pairing behavior is preserved (open gateway)."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
# No SIGNAL_ALLOWED_USERS, no GATEWAY_ALLOWED_USERS
|
||||
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.SIGNAL: PlatformConfig(enabled=True)},
|
||||
)
|
||||
runner, adapter = _make_runner(Platform.SIGNAL, config)
|
||||
runner.pairing_store.generate_code.return_value = "PAIR1234"
|
||||
|
||||
result = await runner._handle_message(
|
||||
_make_event(Platform.SIGNAL, "+15559999999", "+15559999999")
|
||||
)
|
||||
|
||||
assert result is None
|
||||
runner.pairing_store.generate_code.assert_called_once()
|
||||
adapter.send.assert_awaited_once()
|
||||
assert "PAIR1234" in adapter.send.await_args.args[1]
|
||||
|
||||
|
||||
def test_explicit_pair_config_overrides_allowlist_default(monkeypatch):
|
||||
"""Explicit unauthorized_dm_behavior='pair' overrides the allowlist default.
|
||||
|
||||
Operators can opt back in to pairing even with an allowlist by setting
|
||||
unauthorized_dm_behavior: pair in their platform config. We test the
|
||||
_get_unauthorized_dm_behavior resolver directly to avoid the full
|
||||
_handle_message pipeline which requires extensive runner state.
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "+15550000001")
|
||||
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.SIGNAL: PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"unauthorized_dm_behavior": "pair"}, # explicit override
|
||||
),
|
||||
},
|
||||
)
|
||||
runner, _adapter = _make_runner(Platform.SIGNAL, config)
|
||||
|
||||
# The per-platform explicit config should beat the allowlist-derived default
|
||||
behavior = runner._get_unauthorized_dm_behavior(Platform.SIGNAL)
|
||||
assert behavior == "pair"
|
||||
|
||||
|
||||
def test_allowlist_authorized_user_returns_ignore_for_unauthorized(monkeypatch):
|
||||
"""_get_unauthorized_dm_behavior returns 'ignore' when allowlist is set.
|
||||
|
||||
We test the resolver directly. The full _handle_message path for
|
||||
authorized users is covered by the integration tests in this module.
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "+15550000001")
|
||||
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.SIGNAL: PlatformConfig(enabled=True)},
|
||||
)
|
||||
runner, _adapter = _make_runner(Platform.SIGNAL, config)
|
||||
|
||||
behavior = runner._get_unauthorized_dm_behavior(Platform.SIGNAL)
|
||||
assert behavior == "ignore"
|
||||
|
||||
|
||||
def test_get_unauthorized_dm_behavior_no_allowlist_returns_pair(monkeypatch):
|
||||
"""Without any allowlist, 'pair' is still the default."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.SIGNAL: PlatformConfig(enabled=True)},
|
||||
)
|
||||
runner, _adapter = _make_runner(Platform.SIGNAL, config)
|
||||
|
||||
behavior = runner._get_unauthorized_dm_behavior(Platform.SIGNAL)
|
||||
assert behavior == "pair"
|
||||
|
||||
|
||||
def test_qqbot_with_allowlist_ignores_unauthorized_dm(monkeypatch):
|
||||
"""QQBOT is included in the allowlist-aware default (QQ_ALLOWED_USERS).
|
||||
|
||||
Regression guard: the initial #9337 fix omitted QQBOT from the env map
|
||||
inside _get_unauthorized_dm_behavior, even though _is_user_authorized
|
||||
mapped it to QQ_ALLOWED_USERS. Without QQBOT here, a QQ operator with a
|
||||
strict user allowlist would still get pairing codes sent to strangers.
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
monkeypatch.setenv("QQ_ALLOWED_USERS", "allowed-openid-1")
|
||||
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.QQBOT: PlatformConfig(enabled=True)},
|
||||
)
|
||||
runner, _adapter = _make_runner(Platform.QQBOT, config)
|
||||
|
||||
behavior = runner._get_unauthorized_dm_behavior(Platform.QQBOT)
|
||||
assert behavior == "ignore"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user