Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7aa8b1545 | |||
| 1d1e1277e4 | |||
| e017131403 | |||
| c94d26c69b | |||
| 175cf7e6bb | |||
| cd59af17cc | |||
| 361675018f | |||
| 3ade655999 | |||
| 7c10761dd2 | |||
| dca439fe92 | |||
| ce410521b3 | |||
| d66414a844 | |||
| 7b1a11b971 | |||
| 0a8d48809f | |||
| 21d5ef2f17 | |||
| 5b6792f04d | |||
| ba7da73ca9 | |||
| c630dfcdac | |||
| 098efde848 | |||
| 5f9907c116 | |||
| 78586ce036 |
@@ -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]
|
||||
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
name: Supply Chain Audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan PR for supply chain risks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Scan diff for suspicious patterns
|
||||
id: scan
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
# Get the full diff (added lines only)
|
||||
DIFF=$(git diff "$BASE".."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
|
||||
|
||||
FINDINGS=""
|
||||
CRITICAL=false
|
||||
|
||||
# --- .pth files (auto-execute on Python startup) ---
|
||||
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).
|
||||
|
||||
**Files:**
|
||||
\`\`\`
|
||||
${PTH_FILES}
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
# --- base64 + exec/eval combo (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.
|
||||
|
||||
**Matches:**
|
||||
\`\`\`
|
||||
${B64_EXEC_HITS}
|
||||
\`\`\`
|
||||
"
|
||||
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)
|
||||
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.
|
||||
|
||||
**Matches:**
|
||||
\`\`\`
|
||||
${PROC_HITS}
|
||||
\`\`\`
|
||||
"
|
||||
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)
|
||||
if [ -n "$SETUP_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### ⚠️ WARNING: Install hook files modified
|
||||
These files can execute code during package installation or interpreter startup.
|
||||
|
||||
**Files:**
|
||||
\`\`\`
|
||||
${SETUP_HITS}
|
||||
\`\`\`
|
||||
"
|
||||
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
|
||||
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="## ${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.
|
||||
|
||||
$(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.*"
|
||||
|
||||
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'
|
||||
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
|
||||
|
||||
@@ -1810,7 +1810,7 @@ class HermesCLI:
|
||||
mcp_names = set((CLI_CONFIG.get("mcp_servers") or {}).keys())
|
||||
invalid = [t for t in toolsets if not validate_toolset(t) and t not in mcp_names]
|
||||
if invalid:
|
||||
self.console.print(f"[bold red]Warning: Unknown toolsets: {', '.join(invalid)}[/]")
|
||||
self._console_print(f"[bold red]Warning: Unknown toolsets: {', '.join(invalid)}[/]")
|
||||
|
||||
# Filesystem checkpoints: CLI flag > config
|
||||
cp_cfg = CLI_CONFIG.get("checkpoints", {})
|
||||
@@ -2261,7 +2261,7 @@ class HermesCLI:
|
||||
normalized_model = normalize_model_for_provider(current_model, resolved_provider)
|
||||
if normalized_model and normalized_model != current_model:
|
||||
if not self._model_is_default:
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
f"[yellow]⚠️ Normalized model '{current_model}' to '{normalized_model}' for {resolved_provider}.[/]"
|
||||
)
|
||||
self.model = normalized_model
|
||||
@@ -2277,7 +2277,7 @@ class HermesCLI:
|
||||
canonical = normalize_copilot_model_id(current_model, api_key=self.api_key)
|
||||
if canonical and canonical != current_model:
|
||||
if not self._model_is_default:
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
f"[yellow]⚠️ Normalized Copilot model '{current_model}' to '{canonical}'.[/]"
|
||||
)
|
||||
self.model = canonical
|
||||
@@ -2299,7 +2299,7 @@ class HermesCLI:
|
||||
canonical = normalize_opencode_model_id(resolved_provider, current_model)
|
||||
if canonical and canonical != current_model:
|
||||
if not self._model_is_default:
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
f"[yellow]⚠️ Stripped provider prefix from '{current_model}'; using '{canonical}' for {resolved_provider}.[/]"
|
||||
)
|
||||
self.model = canonical
|
||||
@@ -2321,7 +2321,7 @@ class HermesCLI:
|
||||
if "/" in current_model:
|
||||
slug = current_model.split("/", 1)[1]
|
||||
if not self._model_is_default:
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
f"[yellow]⚠️ Stripped provider prefix from '{current_model}'; "
|
||||
f"using '{slug}' for OpenAI Codex.[/]"
|
||||
)
|
||||
@@ -3070,7 +3070,7 @@ class HermesCLI:
|
||||
use_compact = self.compact or term_width < 80
|
||||
|
||||
if use_compact:
|
||||
self.console.print(_build_compact_banner())
|
||||
self._console_print(_build_compact_banner())
|
||||
self._show_status()
|
||||
else:
|
||||
# Get tools for display
|
||||
@@ -3095,25 +3095,25 @@ class HermesCLI:
|
||||
|
||||
# Warn about very low context lengths (common with local servers)
|
||||
if ctx_len and ctx_len <= 8192:
|
||||
self.console.print()
|
||||
self.console.print(
|
||||
self._console_print()
|
||||
self._console_print(
|
||||
f"[yellow]⚠️ Context length is only {ctx_len:,} tokens — "
|
||||
f"this is likely too low for agent use with tools.[/]"
|
||||
)
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
"[dim] Hermes needs 16k–32k minimum. Tool schemas + system prompt alone use ~4k–8k.[/]"
|
||||
)
|
||||
base_url = getattr(self, "base_url", "") or ""
|
||||
if "11434" in base_url or "ollama" in base_url.lower():
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
"[dim] Ollama fix: OLLAMA_CONTEXT_LENGTH=32768 ollama serve[/]"
|
||||
)
|
||||
elif "1234" in base_url:
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
"[dim] LM Studio fix: Set context length in model settings → reload model[/]"
|
||||
)
|
||||
else:
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
"[dim] Fix: Set model.context_length in config.yaml, or increase your server's context setting[/]"
|
||||
)
|
||||
|
||||
@@ -3122,20 +3122,20 @@ class HermesCLI:
|
||||
|
||||
model_name = getattr(self, "model", "") or ""
|
||||
if is_nous_hermes_non_agentic(model_name):
|
||||
self.console.print()
|
||||
self.console.print(
|
||||
self._console_print()
|
||||
self._console_print(
|
||||
"[bold yellow]⚠ Nous Research Hermes 3 & 4 models are NOT agentic and are not "
|
||||
"designed for use with Hermes Agent.[/]"
|
||||
)
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
"[dim] They lack tool-calling capabilities required for agent workflows. "
|
||||
"Consider using an agentic model (Claude, GPT, Gemini, DeepSeek, etc.).[/]"
|
||||
)
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
"[dim] Switch with: /model sonnet or /model gpt5[/]"
|
||||
)
|
||||
|
||||
self.console.print()
|
||||
self._console_print()
|
||||
|
||||
def _preload_resumed_session(self) -> bool:
|
||||
"""Load a resumed session's history from the DB early (before first chat).
|
||||
@@ -3153,10 +3153,10 @@ class HermesCLI:
|
||||
|
||||
session_meta = self._session_db.get_session(self.session_id)
|
||||
if not session_meta:
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
f"[bold red]Session not found: {self.session_id}[/]"
|
||||
)
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
"[dim]Use a session ID from a previous CLI run "
|
||||
"(hermes sessions list).[/]"
|
||||
)
|
||||
@@ -3171,7 +3171,7 @@ class HermesCLI:
|
||||
if session_meta.get("title"):
|
||||
title_part = f' "{session_meta["title"]}"'
|
||||
accent_color = _accent_hex()
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
f"[{accent_color}]↻ Resumed session [bold]{self.session_id}[/bold]"
|
||||
f"{title_part} "
|
||||
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
|
||||
@@ -3179,7 +3179,7 @@ class HermesCLI:
|
||||
)
|
||||
else:
|
||||
accent_color = _accent_hex()
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
f"[{accent_color}]Session {self.session_id} found but has no "
|
||||
f"messages. Starting fresh.[/]"
|
||||
)
|
||||
@@ -3354,7 +3354,7 @@ class HermesCLI:
|
||||
padding=(0, 1),
|
||||
style=_history_text_c,
|
||||
)
|
||||
self.console.print(panel)
|
||||
self._console_print(panel)
|
||||
|
||||
def _try_attach_clipboard_image(self) -> bool:
|
||||
"""Check clipboard for an image and attach it if found.
|
||||
@@ -3790,14 +3790,14 @@ class HermesCLI:
|
||||
api_key_missing = [u for u in unavailable if u["missing_vars"]]
|
||||
|
||||
if api_key_missing:
|
||||
self.console.print()
|
||||
self.console.print("[yellow]⚠️ Some tools disabled (missing API keys):[/]")
|
||||
self._console_print()
|
||||
self._console_print("[yellow]⚠️ Some tools disabled (missing API keys):[/]")
|
||||
for item in api_key_missing:
|
||||
tools_str = ", ".join(item["tools"][:2]) # Show first 2 tools
|
||||
if len(item["tools"]) > 2:
|
||||
tools_str += f", +{len(item['tools'])-2} more"
|
||||
self.console.print(f" [dim]• {item['name']}[/] [dim italic]({', '.join(item['missing_vars'])})[/]")
|
||||
self.console.print("[dim] Run 'hermes setup' to configure[/]")
|
||||
self._console_print(f" [dim]• {item['name']}[/] [dim italic]({', '.join(item['missing_vars'])})[/]")
|
||||
self._console_print("[dim] Run 'hermes setup' to configure[/]")
|
||||
except Exception:
|
||||
pass # Don't crash on import errors
|
||||
|
||||
@@ -3835,7 +3835,7 @@ class HermesCLI:
|
||||
if self._provider_source:
|
||||
provider_info += f" [dim {separator_color}]·[/] [dim]auth: {self._provider_source}[/]"
|
||||
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
f" {api_indicator} [{accent_color}]{model_short}[/] "
|
||||
f"[dim {separator_color}]·[/] [bold {label_color}]{tool_count} tools[/]"
|
||||
f"{toolsets_info}{provider_info}"
|
||||
@@ -3892,7 +3892,7 @@ class HermesCLI:
|
||||
f"Tokens: {total_tokens:,}",
|
||||
f"Agent Running: {'Yes' if is_running else 'No'}",
|
||||
])
|
||||
self.console.print("\n".join(lines), highlight=False, markup=False)
|
||||
self._console_print("\n".join(lines), highlight=False, markup=False)
|
||||
|
||||
def _fast_command_available(self) -> bool:
|
||||
try:
|
||||
@@ -5090,8 +5090,15 @@ class HermesCLI:
|
||||
|
||||
print(" To change model or provider, use: hermes model")
|
||||
|
||||
def _output_console(self):
|
||||
"""Use prompt_toolkit-safe Rich rendering once the TUI is live."""
|
||||
if getattr(self, "_app", None):
|
||||
return ChatConsole()
|
||||
return self.console
|
||||
|
||||
|
||||
def _console_print(self, *args, **kwargs):
|
||||
"""Print through the active command-safe console."""
|
||||
self._output_console().print(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_personality_prompt(value) -> str:
|
||||
@@ -5111,14 +5118,14 @@ class HermesCLI:
|
||||
from agent.google_oauth import get_valid_access_token, GoogleOAuthError, load_credentials
|
||||
from agent.google_code_assist import retrieve_user_quota, CodeAssistError
|
||||
except ImportError as exc:
|
||||
self.console.print(f" [red]Gemini modules unavailable: {exc}[/]")
|
||||
self._console_print(f" [red]Gemini modules unavailable: {exc}[/]")
|
||||
return
|
||||
|
||||
try:
|
||||
access_token = get_valid_access_token()
|
||||
except GoogleOAuthError as exc:
|
||||
self.console.print(f" [yellow]{exc}[/]")
|
||||
self.console.print(" Run [bold]/model[/] and pick 'Google Gemini (OAuth)' to sign in.")
|
||||
self._console_print(f" [yellow]{exc}[/]")
|
||||
self._console_print(" Run [bold]/model[/] and pick 'Google Gemini (OAuth)' to sign in.")
|
||||
return
|
||||
|
||||
creds = load_credentials()
|
||||
@@ -5127,18 +5134,18 @@ class HermesCLI:
|
||||
try:
|
||||
buckets = retrieve_user_quota(access_token, project_id=project_id)
|
||||
except CodeAssistError as exc:
|
||||
self.console.print(f" [red]Quota lookup failed:[/] {exc}")
|
||||
self._console_print(f" [red]Quota lookup failed:[/] {exc}")
|
||||
return
|
||||
|
||||
if not buckets:
|
||||
self.console.print(" [dim]No quota buckets reported (account may be on legacy/unmetered tier).[/]")
|
||||
self._console_print(" [dim]No quota buckets reported (account may be on legacy/unmetered tier).[/]")
|
||||
return
|
||||
|
||||
# Sort for stable display, group by model
|
||||
buckets.sort(key=lambda b: (b.model_id, b.token_type))
|
||||
self.console.print()
|
||||
self.console.print(f" [bold]Gemini Code Assist quota[/] (project: {project_id or '(auto / free-tier)'})")
|
||||
self.console.print()
|
||||
self._console_print()
|
||||
self._console_print(f" [bold]Gemini Code Assist quota[/] (project: {project_id or '(auto / free-tier)'})")
|
||||
self._console_print()
|
||||
for b in buckets:
|
||||
pct = max(0.0, min(1.0, b.remaining_fraction))
|
||||
width = 20
|
||||
@@ -5148,8 +5155,8 @@ class HermesCLI:
|
||||
header = b.model_id
|
||||
if b.token_type:
|
||||
header += f" [{b.token_type}]"
|
||||
self.console.print(f" {header:40s} {bar} {pct_str}")
|
||||
self.console.print()
|
||||
self._console_print(f" {header:40s} {bar} {pct_str}")
|
||||
self._console_print()
|
||||
|
||||
def _handle_personality_command(self, cmd: str):
|
||||
"""Handle the /personality command to set predefined personalities."""
|
||||
@@ -5597,7 +5604,7 @@ class HermesCLI:
|
||||
_tip_color = get_active_skin().get_color("banner_dim", "#B8860B")
|
||||
except Exception:
|
||||
_tip_color = "#B8860B"
|
||||
self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
|
||||
self._console_print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
|
||||
except Exception:
|
||||
pass
|
||||
elif canonical == "history":
|
||||
@@ -5691,7 +5698,7 @@ class HermesCLI:
|
||||
elif canonical == "statusbar":
|
||||
self._status_bar_visible = not self._status_bar_visible
|
||||
state = "visible" if self._status_bar_visible else "hidden"
|
||||
self.console.print(f" Status bar {state}")
|
||||
self._console_print(f" Status bar {state}")
|
||||
elif canonical == "verbose":
|
||||
self._toggle_verbose()
|
||||
elif canonical == "yolo":
|
||||
@@ -5814,15 +5821,15 @@ class HermesCLI:
|
||||
)
|
||||
output = result.stdout.strip() or result.stderr.strip()
|
||||
if output:
|
||||
self.console.print(_rich_text_from_ansi(output))
|
||||
self._console_print(_rich_text_from_ansi(output))
|
||||
else:
|
||||
self.console.print("[dim]Command returned no output[/]")
|
||||
self._console_print("[dim]Command returned no output[/]")
|
||||
except subprocess.TimeoutExpired:
|
||||
self.console.print("[bold red]Quick command timed out (30s)[/]")
|
||||
self._console_print("[bold red]Quick command timed out (30s)[/]")
|
||||
except Exception as e:
|
||||
self.console.print(f"[bold red]Quick command error: {e}[/]")
|
||||
self._console_print(f"[bold red]Quick command error: {e}[/]")
|
||||
else:
|
||||
self.console.print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
|
||||
self._console_print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
|
||||
elif qcmd.get("type") == "alias":
|
||||
target = qcmd.get("target", "").strip()
|
||||
if target:
|
||||
@@ -5831,9 +5838,9 @@ class HermesCLI:
|
||||
aliased_command = f"{target} {user_args}".strip()
|
||||
return self.process_command(aliased_command)
|
||||
else:
|
||||
self.console.print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
|
||||
self._console_print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
|
||||
else:
|
||||
self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
|
||||
self._console_print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
|
||||
# Check for plugin-registered slash commands
|
||||
elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names():
|
||||
from hermes_cli.plugins import get_plugin_command_handler
|
||||
@@ -8603,7 +8610,7 @@ class HermesCLI:
|
||||
except Exception:
|
||||
_welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands."
|
||||
_welcome_color = "#FFF8DC"
|
||||
self.console.print(f"[{_welcome_color}]{_welcome_text}[/]")
|
||||
self._console_print(f"[{_welcome_color}]{_welcome_text}[/]")
|
||||
# Show a random tip to help users discover features
|
||||
try:
|
||||
from hermes_cli.tips import get_random_tip
|
||||
@@ -8612,16 +8619,16 @@ class HermesCLI:
|
||||
_tip_color = _welcome_skin.get_color("banner_dim", "#B8860B")
|
||||
except Exception:
|
||||
_tip_color = "#B8860B"
|
||||
self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
|
||||
self._console_print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
|
||||
except Exception:
|
||||
pass # Tips are non-critical — never break startup
|
||||
if self.preloaded_skills and not self._startup_skills_line_shown:
|
||||
skills_label = ", ".join(self.preloaded_skills)
|
||||
self.console.print(
|
||||
self._console_print(
|
||||
f"[bold {_accent_hex()}]Activated skills:[/] {skills_label}"
|
||||
)
|
||||
self._startup_skills_line_shown = True
|
||||
self.console.print()
|
||||
self._console_print()
|
||||
|
||||
# State for async operation
|
||||
self._agent_running = False
|
||||
|
||||
+65
-4
@@ -564,15 +564,53 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
return False, f"Script execution failed: {exc}"
|
||||
|
||||
|
||||
def _build_job_prompt(job: dict) -> str:
|
||||
"""Build the effective prompt for a cron job, optionally loading one or more skills first."""
|
||||
def _parse_wake_gate(script_output: str) -> bool:
|
||||
"""Parse the last non-empty stdout line of a cron job's pre-check script
|
||||
as a wake gate.
|
||||
|
||||
The convention (ported from nanoclaw #1232): if the last stdout line is
|
||||
JSON like ``{"wakeAgent": false}``, the agent is skipped entirely — no
|
||||
LLM run, no delivery. Any other output (non-JSON, missing flag, gate
|
||||
absent, or ``wakeAgent: true``) means wake the agent normally.
|
||||
|
||||
Returns True if the agent should wake, False to skip.
|
||||
"""
|
||||
if not script_output:
|
||||
return True
|
||||
stripped_lines = [line for line in script_output.splitlines() if line.strip()]
|
||||
if not stripped_lines:
|
||||
return True
|
||||
last_line = stripped_lines[-1].strip()
|
||||
try:
|
||||
gate = json.loads(last_line)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return True
|
||||
if not isinstance(gate, dict):
|
||||
return True
|
||||
return gate.get("wakeAgent", True) is not False
|
||||
|
||||
|
||||
def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
"""Build the effective prompt for a cron job, optionally loading one or more skills first.
|
||||
|
||||
Args:
|
||||
job: The cron job dict.
|
||||
prerun_script: Optional ``(success, stdout)`` from a script that has
|
||||
already been executed by the caller (e.g. for a wake-gate check).
|
||||
When provided, the script is not re-executed and the cached
|
||||
result is used for prompt injection. When omitted, the script
|
||||
(if any) runs inline as before.
|
||||
"""
|
||||
prompt = job.get("prompt", "")
|
||||
skills = job.get("skills")
|
||||
|
||||
# Run data-collection script if configured, inject output as context.
|
||||
script_path = job.get("script")
|
||||
if script_path:
|
||||
success, script_output = _run_job_script(script_path)
|
||||
if prerun_script is not None:
|
||||
success, script_output = prerun_script
|
||||
else:
|
||||
success, script_output = _run_job_script(script_path)
|
||||
if success:
|
||||
if script_output:
|
||||
prompt = (
|
||||
@@ -674,7 +712,30 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
|
||||
job_id = job["id"]
|
||||
job_name = job["name"]
|
||||
prompt = _build_job_prompt(job)
|
||||
|
||||
# Wake-gate: if this job has a pre-check script, run it BEFORE building
|
||||
# the prompt so a ``{"wakeAgent": false}`` response can short-circuit
|
||||
# the whole agent run. We pass the result into _build_job_prompt so
|
||||
# the script is only executed once.
|
||||
prerun_script = None
|
||||
script_path = job.get("script")
|
||||
if script_path:
|
||||
prerun_script = _run_job_script(script_path)
|
||||
_ran_ok, _script_output = prerun_script
|
||||
if _ran_ok and not _parse_wake_gate(_script_output):
|
||||
logger.info(
|
||||
"Job '%s' (ID: %s): wakeAgent=false, skipping agent run",
|
||||
job_name, job_id,
|
||||
)
|
||||
silent_doc = (
|
||||
f"# Cron Job: {job_name}\n\n"
|
||||
f"**Job ID:** {job_id}\n"
|
||||
f"**Run Time:** {_hermes_now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
||||
"Script gate returned `wakeAgent=false` — agent skipped.\n"
|
||||
)
|
||||
return True, silent_doc, SILENT_MARKER, None
|
||||
|
||||
prompt = _build_job_prompt(job, prerun_script=prerun_script)
|
||||
origin = _resolve_origin(job)
|
||||
_cron_session_id = f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
|
||||
@@ -3265,7 +3265,20 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
"[Discord] Flushing text batch %s (%d chars)",
|
||||
key, len(event.text or ""),
|
||||
)
|
||||
await self.handle_message(event)
|
||||
# Shield the downstream dispatch so that a subsequent chunk
|
||||
# arriving while handle_message is mid-flight cannot cancel
|
||||
# the running agent turn. _enqueue_text_event always cancels
|
||||
# the prior flush task when a new chunk lands; without this
|
||||
# shield, CancelledError would propagate from our task down
|
||||
# into handle_message → the agent's streaming request,
|
||||
# aborting the response the user was waiting on. The new
|
||||
# chunk is handled by the fresh flush task regardless.
|
||||
await asyncio.shield(self.handle_message(event))
|
||||
except asyncio.CancelledError:
|
||||
# Only reached if cancel landed before the pop — the shielded
|
||||
# handle_message is unaffected either way. Let the task exit
|
||||
# cleanly so the finally block cleans up.
|
||||
pass
|
||||
finally:
|
||||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||||
self._pending_text_batch_tasks.pop(key, None)
|
||||
|
||||
@@ -430,6 +430,21 @@ class GatewayStreamConsumer:
|
||||
# a real string like "msg_1", not "__no_edit__", so that case
|
||||
# still resets and creates a fresh segment as intended.)
|
||||
if got_segment_break:
|
||||
# If the segment-break edit failed to deliver the
|
||||
# accumulated content (flood control that has not yet
|
||||
# promoted to fallback mode, or fallback mode itself),
|
||||
# _accumulated still holds pre-boundary text the user
|
||||
# never saw. Flush that tail as a continuation message
|
||||
# before the reset below wipes _accumulated — otherwise
|
||||
# text generated before the tool boundary is silently
|
||||
# dropped (issue #8124).
|
||||
if (
|
||||
self._accumulated
|
||||
and not current_update_visible
|
||||
and self._message_id
|
||||
and self._message_id != "__no_edit__"
|
||||
):
|
||||
await self._flush_segment_tail_on_edit_failure()
|
||||
self._reset_segment_state(preserve_no_edit=True)
|
||||
|
||||
await asyncio.sleep(0.05) # Small yield to not busy-loop
|
||||
@@ -620,6 +635,39 @@ class GatewayStreamConsumer:
|
||||
err_lower = err.lower()
|
||||
return "flood" in err_lower or "retry after" in err_lower or "rate" in err_lower
|
||||
|
||||
async def _flush_segment_tail_on_edit_failure(self) -> None:
|
||||
"""Deliver un-sent tail content before a segment-break reset.
|
||||
|
||||
When an edit fails (flood control, transport error) and a tool
|
||||
boundary arrives before the next retry, ``_accumulated`` holds text
|
||||
that was generated but never shown to the user. Without this flush,
|
||||
the segment reset would discard that tail and leave a frozen cursor
|
||||
in the partial message.
|
||||
|
||||
Sends the tail that sits after the last successfully-delivered
|
||||
prefix as a new message, and best-effort strips the stuck cursor
|
||||
from the previous partial message.
|
||||
"""
|
||||
if not self._fallback_final_send:
|
||||
await self._try_strip_cursor()
|
||||
visible = self._fallback_prefix or self._visible_prefix()
|
||||
tail = self._accumulated
|
||||
if visible and tail.startswith(visible):
|
||||
tail = tail[len(visible):].lstrip()
|
||||
tail = self._clean_for_display(tail)
|
||||
if not tail.strip():
|
||||
return
|
||||
try:
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
content=tail,
|
||||
metadata=self.metadata,
|
||||
)
|
||||
if result.success:
|
||||
self._already_sent = True
|
||||
except Exception as e:
|
||||
logger.error("Segment-break tail flush error: %s", e)
|
||||
|
||||
async def _try_strip_cursor(self) -> None:
|
||||
"""Best-effort edit to remove the cursor from the last visible message.
|
||||
|
||||
|
||||
@@ -2861,7 +2861,7 @@ _FALLBACK_COMMENT = """
|
||||
# minimax (MINIMAX_API_KEY) — MiniMax
|
||||
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
|
||||
#
|
||||
# For custom OpenAI-compatible endpoints, add base_url and api_key_env.
|
||||
# For custom OpenAI-compatible endpoints, add base_url and key_env.
|
||||
#
|
||||
# fallback_model:
|
||||
# provider: openrouter
|
||||
@@ -2905,7 +2905,7 @@ _COMMENTED_SECTIONS = """
|
||||
# minimax (MINIMAX_API_KEY) — MiniMax
|
||||
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
|
||||
#
|
||||
# For custom OpenAI-compatible endpoints, add base_url and api_key_env.
|
||||
# For custom OpenAI-compatible endpoints, add base_url and key_env.
|
||||
#
|
||||
# fallback_model:
|
||||
# provider: openrouter
|
||||
|
||||
+3
-1
@@ -1460,7 +1460,9 @@ def setup_agent_settings(config: dict):
|
||||
)
|
||||
print_info("Maximum tool-calling iterations per conversation.")
|
||||
print_info("Higher = more complex tasks, but costs more tokens.")
|
||||
print_info("Default is 90, which works for most tasks. Use 150+ for open exploration.")
|
||||
print_info(
|
||||
f"Press Enter to keep {current_max}. Use 90 for most tasks or 150+ for open exploration."
|
||||
)
|
||||
|
||||
max_iter_str = prompt("Max iterations", current_max)
|
||||
try:
|
||||
|
||||
@@ -145,10 +145,10 @@ Controls **how often** dialectic and context calls happen.
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `contextCadence` | `1` | Min turns between context API calls |
|
||||
| `dialecticCadence` | `3` | Min turns between dialectic API calls |
|
||||
| `dialecticCadence` | `2` | Min turns between dialectic API calls. Recommended 1–5 |
|
||||
| `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` for base context injection |
|
||||
|
||||
Higher cadence values reduce API calls and cost. `dialecticCadence: 3` (default) means the dialectic engine fires at most every 3rd turn.
|
||||
Higher cadence values fire the dialectic LLM less often. `dialecticCadence: 2` means the engine fires every other turn. Setting it to `1` fires every turn.
|
||||
|
||||
### Depth (how many)
|
||||
|
||||
@@ -180,6 +180,8 @@ If `dialecticDepthLevels` is omitted, rounds use **proportional levels** derived
|
||||
|
||||
This keeps earlier passes cheap while using full depth on the final synthesis.
|
||||
|
||||
**Depth at session start.** The session-start prewarm runs the full configured `dialecticDepth` in the background before turn 1. A single-pass prewarm on a cold peer often returns thin output — multi-pass depth runs the audit/reconcile cycle before the user ever speaks. Turn 1 consumes the prewarm result directly; if prewarm hasn't landed in time, turn 1 falls back to a synchronous call with a bounded timeout.
|
||||
|
||||
### Level (how hard)
|
||||
|
||||
Controls the **intensity** of each dialectic reasoning round.
|
||||
@@ -368,7 +370,7 @@ Config file: `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.jso
|
||||
| `contextTokens` | uncapped | Max tokens for the combined base context injection (summary + representation + card). Opt-in cap — omit to leave uncapped, set to an integer to bound injection size. |
|
||||
| `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` |
|
||||
| `contextCadence` | `1` | Min turns between context API calls |
|
||||
| `dialecticCadence` | `3` | Min turns between dialectic LLM calls |
|
||||
| `dialecticCadence` | `2` | Min turns between dialectic LLM calls (recommended 1–5) |
|
||||
|
||||
The `contextTokens` budget is enforced at injection time. If the session summary + representation + card exceed the budget, Honcho trims the summary first, then the representation, preserving the card. This prevents context blowup in long sessions.
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
@@ -206,13 +207,19 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
self._turn_count = 0
|
||||
self._injection_frequency = "every-turn" # or "first-turn"
|
||||
self._context_cadence = 1 # minimum turns between context API calls
|
||||
self._dialectic_cadence = 3 # minimum turns between dialectic API calls
|
||||
self._dialectic_cadence = 1 # backwards-compat fallback; wizard writes 2 on new configs
|
||||
self._dialectic_depth = 1 # how many .chat() calls per dialectic cycle (1-3)
|
||||
self._dialectic_depth_levels: list[str] | None = None # per-pass reasoning levels
|
||||
self._reasoning_level_cap: Optional[str] = None # "minimal", "low", "medium", "high"
|
||||
self._reasoning_heuristic: bool = True # scale base level by query length
|
||||
self._reasoning_level_cap: str = "high" # ceiling for auto-selected level
|
||||
self._last_context_turn = -999
|
||||
self._last_dialectic_turn = -999
|
||||
|
||||
# Liveness + observability state
|
||||
self._prefetch_thread_started_at: float = 0.0 # monotonic ts of current thread
|
||||
self._prefetch_result_fired_at: int = -999 # turn the pending result was fired at
|
||||
self._dialectic_empty_streak: int = 0 # consecutive empty returns
|
||||
|
||||
# Port #1957: lazy session init for tools-only mode
|
||||
self._session_initialized = False
|
||||
self._lazy_init_kwargs: Optional[dict] = None
|
||||
@@ -286,14 +293,6 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
logger.debug("Honcho not configured — plugin inactive")
|
||||
return
|
||||
|
||||
# Override peer_name with gateway user_id for per-user memory scoping.
|
||||
# Only when no explicit peerName was configured — an explicit peerName
|
||||
# means the user chose their identity; a raw user_id (e.g. Telegram
|
||||
# chat ID) should not silently replace it.
|
||||
_gw_user_id = kwargs.get("user_id")
|
||||
if _gw_user_id and not cfg.peer_name:
|
||||
cfg.peer_name = _gw_user_id
|
||||
|
||||
self._config = cfg
|
||||
|
||||
# ----- B1: recall_mode from config -----
|
||||
@@ -305,12 +304,16 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
raw = cfg.raw or {}
|
||||
self._injection_frequency = raw.get("injectionFrequency", "every-turn")
|
||||
self._context_cadence = int(raw.get("contextCadence", 1))
|
||||
self._dialectic_cadence = int(raw.get("dialecticCadence", 3))
|
||||
# Backwards-compat: unset dialecticCadence falls back to 1
|
||||
# (every turn) so existing honcho.json configs without the key
|
||||
# behave as they did before. New setups via `hermes honcho setup`
|
||||
# get dialecticCadence=2 written explicitly by the wizard.
|
||||
self._dialectic_cadence = int(raw.get("dialecticCadence", 1))
|
||||
self._dialectic_depth = max(1, min(cfg.dialectic_depth, 3))
|
||||
self._dialectic_depth_levels = cfg.dialectic_depth_levels
|
||||
cap = raw.get("reasoningLevelCap")
|
||||
if cap and cap in ("minimal", "low", "medium", "high"):
|
||||
self._reasoning_level_cap = cap
|
||||
self._reasoning_heuristic = cfg.reasoning_heuristic
|
||||
if cfg.reasoning_level_cap in self._LEVEL_ORDER:
|
||||
self._reasoning_level_cap = cfg.reasoning_level_cap
|
||||
except Exception as e:
|
||||
logger.debug("Honcho cost-awareness config parse error: %s", e)
|
||||
|
||||
@@ -352,6 +355,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
honcho=client,
|
||||
config=cfg,
|
||||
context_tokens=cfg.context_tokens,
|
||||
runtime_user_peer_name=kwargs.get("user_id") or None,
|
||||
)
|
||||
|
||||
# ----- B3: resolve_session_name -----
|
||||
@@ -391,14 +395,45 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
except Exception as e:
|
||||
logger.debug("Honcho memory file migration skipped: %s", e)
|
||||
|
||||
# ----- B7: Pre-warming context at init -----
|
||||
# ----- B7: Pre-warming at init -----
|
||||
# Context prewarm warms peer.context() (base layer), consumed via
|
||||
# pop_context_result() in prefetch(). Dialectic prewarm runs the
|
||||
# full configured depth and writes into _prefetch_result so turn 1
|
||||
# consumes the result directly.
|
||||
if self._recall_mode in ("context", "hybrid"):
|
||||
try:
|
||||
self._manager.prefetch_context(self._session_key)
|
||||
self._manager.prefetch_dialectic(self._session_key, "What should I know about this user?")
|
||||
logger.debug("Honcho pre-warm threads started for session: %s", self._session_key)
|
||||
except Exception as e:
|
||||
logger.debug("Honcho pre-warm failed: %s", e)
|
||||
logger.debug("Honcho context prewarm failed: %s", e)
|
||||
|
||||
_prewarm_query = (
|
||||
"Summarize what you know about this user. "
|
||||
"Focus on preferences, current projects, and working style."
|
||||
)
|
||||
|
||||
def _prewarm_dialectic() -> None:
|
||||
try:
|
||||
r = self._run_dialectic_depth(_prewarm_query)
|
||||
except Exception as exc:
|
||||
logger.debug("Honcho dialectic prewarm failed: %s", exc)
|
||||
self._dialectic_empty_streak += 1
|
||||
return
|
||||
if r and r.strip():
|
||||
with self._prefetch_lock:
|
||||
self._prefetch_result = r
|
||||
self._prefetch_result_fired_at = 0
|
||||
# Treat prewarm as turn 0 so cadence gating starts clean.
|
||||
self._last_dialectic_turn = 0
|
||||
self._dialectic_empty_streak = 0
|
||||
else:
|
||||
self._dialectic_empty_streak += 1
|
||||
|
||||
self._prefetch_thread_started_at = time.monotonic()
|
||||
self._prefetch_thread = threading.Thread(
|
||||
target=_prewarm_dialectic, daemon=True, name="honcho-prewarm-dialectic"
|
||||
)
|
||||
self._prefetch_thread.start()
|
||||
logger.debug("Honcho pre-warm started for session: %s", self._session_key)
|
||||
|
||||
def _ensure_session(self) -> bool:
|
||||
"""Lazily initialize the Honcho session (for tools-only mode).
|
||||
@@ -487,7 +522,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
"# Honcho Memory\n"
|
||||
"Active (tools-only mode). Use honcho_profile for a quick factual snapshot, "
|
||||
"honcho_search for raw excerpts, honcho_context for raw peer context, "
|
||||
"honcho_reasoning for synthesized answers, "
|
||||
"honcho_reasoning for synthesized answers (pass reasoning_level "
|
||||
"minimal/low/medium/high/max — you pick the depth per call), "
|
||||
"honcho_conclude to save facts about the user. "
|
||||
"No automatic context injection — you must use tools to access memory."
|
||||
)
|
||||
@@ -497,7 +533,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
"Active (hybrid mode). Relevant context is auto-injected AND memory tools are available. "
|
||||
"Use honcho_profile for a quick factual snapshot, "
|
||||
"honcho_search for raw excerpts, honcho_context for raw peer context, "
|
||||
"honcho_reasoning for synthesized answers, "
|
||||
"honcho_reasoning for synthesized answers (pass reasoning_level "
|
||||
"minimal/low/medium/high/max — you pick the depth per call), "
|
||||
"honcho_conclude to save facts about the user."
|
||||
)
|
||||
|
||||
@@ -526,6 +563,10 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
if self._injection_frequency == "first-turn" and self._turn_count > 1:
|
||||
return ""
|
||||
|
||||
# Trivial prompts ("ok", "yes", slash commands) carry no semantic signal.
|
||||
if self._is_trivial_prompt(query):
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
|
||||
# ----- Layer 1: Base context (representation + card) -----
|
||||
@@ -560,43 +601,72 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
# On the very first turn, no queue_prefetch() has run yet so the
|
||||
# dialectic result is empty. Run with a bounded timeout so a slow
|
||||
# Honcho connection doesn't block the first response indefinitely.
|
||||
# On timeout the result is skipped and queue_prefetch() will pick it
|
||||
# up at the next cadence-allowed turn.
|
||||
# On timeout we let the thread keep running and write its result into
|
||||
# _prefetch_result under the lock, so the next turn picks it up.
|
||||
#
|
||||
# Skip if the session-start prewarm already filled _prefetch_result —
|
||||
# firing another .chat() would be duplicate work.
|
||||
with self._prefetch_lock:
|
||||
_prewarm_landed = bool(self._prefetch_result)
|
||||
if _prewarm_landed and self._last_dialectic_turn == -999:
|
||||
self._last_dialectic_turn = self._turn_count
|
||||
|
||||
if self._last_dialectic_turn == -999 and query:
|
||||
_first_turn_timeout = (
|
||||
self._config.timeout if self._config and self._config.timeout else 8.0
|
||||
)
|
||||
_result_holder: list[str] = []
|
||||
_fired_at = self._turn_count
|
||||
|
||||
def _run_first_turn() -> None:
|
||||
try:
|
||||
_result_holder.append(self._run_dialectic_depth(query))
|
||||
r = self._run_dialectic_depth(query)
|
||||
except Exception as exc:
|
||||
logger.debug("Honcho first-turn dialectic failed: %s", exc)
|
||||
|
||||
_t = threading.Thread(target=_run_first_turn, daemon=True)
|
||||
_t.start()
|
||||
_t.join(timeout=_first_turn_timeout)
|
||||
if not _t.is_alive():
|
||||
first_turn_dialectic = _result_holder[0] if _result_holder else ""
|
||||
if first_turn_dialectic and first_turn_dialectic.strip():
|
||||
self._dialectic_empty_streak += 1
|
||||
return
|
||||
if r and r.strip():
|
||||
with self._prefetch_lock:
|
||||
self._prefetch_result = first_turn_dialectic
|
||||
self._last_dialectic_turn = self._turn_count
|
||||
else:
|
||||
self._prefetch_result = r
|
||||
self._prefetch_result_fired_at = _fired_at
|
||||
# Advance cadence only on a non-empty result so the next
|
||||
# turn retries when the call returned nothing.
|
||||
self._last_dialectic_turn = _fired_at
|
||||
self._dialectic_empty_streak = 0
|
||||
else:
|
||||
self._dialectic_empty_streak += 1
|
||||
|
||||
self._prefetch_thread_started_at = time.monotonic()
|
||||
self._prefetch_thread = threading.Thread(
|
||||
target=_run_first_turn, daemon=True, name="honcho-prefetch-first"
|
||||
)
|
||||
self._prefetch_thread.start()
|
||||
self._prefetch_thread.join(timeout=_first_turn_timeout)
|
||||
if self._prefetch_thread.is_alive():
|
||||
logger.debug(
|
||||
"Honcho first-turn dialectic timed out (%.1fs) — "
|
||||
"will inject at next cadence-allowed turn",
|
||||
"Honcho first-turn dialectic still running after %.1fs — "
|
||||
"will surface on next turn",
|
||||
_first_turn_timeout,
|
||||
)
|
||||
# Don't update _last_dialectic_turn: queue_prefetch() will
|
||||
# retry at the next cadence-allowed turn via the async path.
|
||||
|
||||
if self._prefetch_thread and self._prefetch_thread.is_alive():
|
||||
self._prefetch_thread.join(timeout=3.0)
|
||||
with self._prefetch_lock:
|
||||
dialectic_result = self._prefetch_result
|
||||
fired_at = self._prefetch_result_fired_at
|
||||
self._prefetch_result = ""
|
||||
self._prefetch_result_fired_at = -999
|
||||
|
||||
# Discard stale pending results: if the fire happened more than
|
||||
# cadence × multiplier turns ago (e.g. a run of trivial-prompt turns
|
||||
# passed without consumption), the content likely no longer tracks
|
||||
# the current conversational pivot.
|
||||
stale_limit = self._dialectic_cadence * self._STALE_RESULT_MULTIPLIER
|
||||
if dialectic_result and fired_at >= 0 and (self._turn_count - fired_at) > stale_limit:
|
||||
logger.debug(
|
||||
"Honcho pending dialectic discarded as stale: fired_at=%d, "
|
||||
"turn=%d, limit=%d", fired_at, self._turn_count, stale_limit,
|
||||
)
|
||||
dialectic_result = ""
|
||||
|
||||
if dialectic_result and dialectic_result.strip():
|
||||
parts.append(dialectic_result)
|
||||
@@ -641,6 +711,10 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
if self._recall_mode == "tools":
|
||||
return
|
||||
|
||||
# Trivial prompts don't warrant either a context refresh or a dialectic call.
|
||||
if self._is_trivial_prompt(query):
|
||||
return
|
||||
|
||||
# ----- Context refresh (base layer) — independent cadence -----
|
||||
if self._context_cadence <= 1 or (self._turn_count - self._last_context_turn) >= self._context_cadence:
|
||||
self._last_context_turn = self._turn_count
|
||||
@@ -650,24 +724,46 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
logger.debug("Honcho context prefetch failed: %s", e)
|
||||
|
||||
# ----- Dialectic prefetch (supplement layer) -----
|
||||
# B5: cadence check — skip if too soon since last dialectic call
|
||||
if self._dialectic_cadence > 1:
|
||||
if (self._turn_count - self._last_dialectic_turn) < self._dialectic_cadence:
|
||||
logger.debug("Honcho dialectic prefetch skipped: cadence %d, turns since last: %d",
|
||||
self._dialectic_cadence, self._turn_count - self._last_dialectic_turn)
|
||||
return
|
||||
# Thread-alive guard with stale-thread recovery: a hung Honcho call
|
||||
# older than timeout × multiplier is treated as dead so it can't
|
||||
# block subsequent fires.
|
||||
if self._thread_is_live():
|
||||
logger.debug("Honcho dialectic prefetch skipped: prior thread still running")
|
||||
return
|
||||
|
||||
self._last_dialectic_turn = self._turn_count
|
||||
# Cadence gate, widened by the empty-streak backoff so a persistently
|
||||
# silent backend doesn't retry every turn forever.
|
||||
effective = self._effective_cadence()
|
||||
if (self._turn_count - self._last_dialectic_turn) < effective:
|
||||
logger.debug(
|
||||
"Honcho dialectic prefetch skipped: effective cadence %d "
|
||||
"(base %d, empty streak %d), turns since last: %d",
|
||||
effective, self._dialectic_cadence, self._dialectic_empty_streak,
|
||||
self._turn_count - self._last_dialectic_turn,
|
||||
)
|
||||
return
|
||||
|
||||
# Cadence advances only on a non-empty result so empty returns
|
||||
# (transient API error, sparse representation) retry next turn.
|
||||
_fired_at = self._turn_count
|
||||
|
||||
def _run():
|
||||
try:
|
||||
result = self._run_dialectic_depth(query)
|
||||
if result and result.strip():
|
||||
with self._prefetch_lock:
|
||||
self._prefetch_result = result
|
||||
except Exception as e:
|
||||
logger.debug("Honcho prefetch failed: %s", e)
|
||||
self._dialectic_empty_streak += 1
|
||||
return
|
||||
if result and result.strip():
|
||||
with self._prefetch_lock:
|
||||
self._prefetch_result = result
|
||||
self._prefetch_result_fired_at = _fired_at
|
||||
self._last_dialectic_turn = _fired_at
|
||||
self._dialectic_empty_streak = 0
|
||||
else:
|
||||
self._dialectic_empty_streak += 1
|
||||
|
||||
self._prefetch_thread_started_at = time.monotonic()
|
||||
self._prefetch_thread = threading.Thread(
|
||||
target=_run, daemon=True, name="honcho-prefetch"
|
||||
)
|
||||
@@ -692,11 +788,91 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
|
||||
_LEVEL_ORDER = ("minimal", "low", "medium", "high", "max")
|
||||
|
||||
def _resolve_pass_level(self, pass_idx: int) -> str:
|
||||
# Char-count thresholds for the query-length reasoning heuristic.
|
||||
_HEURISTIC_LENGTH_MEDIUM = 120
|
||||
_HEURISTIC_LENGTH_HIGH = 400
|
||||
|
||||
# Liveness constants. A thread older than timeout × multiplier is treated
|
||||
# as dead so a hung Honcho call can't block future retries indefinitely.
|
||||
_STALE_THREAD_MULTIPLIER = 2.0
|
||||
# Pending result whose fire-turn is older than cadence × multiplier is
|
||||
# discarded on read so we don't inject context for a stale conversational
|
||||
# pivot after a gap of trivial-prompt turns.
|
||||
_STALE_RESULT_MULTIPLIER = 2
|
||||
# Cap on the empty-streak backoff so a persistently silent backend
|
||||
# eventually settles on a ceiling instead of unbounded widening.
|
||||
_BACKOFF_MAX = 8
|
||||
|
||||
def _thread_is_live(self) -> bool:
|
||||
"""Thread-alive guard that treats threads older than the stale
|
||||
threshold as dead, so a hung Honcho request can't block new fires."""
|
||||
if not self._prefetch_thread or not self._prefetch_thread.is_alive():
|
||||
return False
|
||||
timeout = (self._config.timeout if self._config and self._config.timeout else 8.0)
|
||||
age = time.monotonic() - self._prefetch_thread_started_at
|
||||
if age > timeout * self._STALE_THREAD_MULTIPLIER:
|
||||
logger.debug(
|
||||
"Honcho prefetch thread age %.1fs exceeds stale threshold "
|
||||
"%.1fs — treating as dead", age, timeout * self._STALE_THREAD_MULTIPLIER,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _effective_cadence(self) -> int:
|
||||
"""Cadence plus empty-streak backoff, capped at _BACKOFF_MAX × base."""
|
||||
if self._dialectic_empty_streak <= 0:
|
||||
return self._dialectic_cadence
|
||||
widened = self._dialectic_cadence + self._dialectic_empty_streak
|
||||
ceiling = self._dialectic_cadence * self._BACKOFF_MAX
|
||||
return min(widened, ceiling)
|
||||
|
||||
def liveness_snapshot(self) -> dict:
|
||||
"""In-process snapshot of dialectic liveness state for diagnostics.
|
||||
|
||||
Returns current turn, last successful dialectic turn, pending-result
|
||||
fire turn, empty streak, effective cadence, and thread status.
|
||||
"""
|
||||
thread_age = None
|
||||
if self._prefetch_thread and self._prefetch_thread.is_alive():
|
||||
thread_age = time.monotonic() - self._prefetch_thread_started_at
|
||||
return {
|
||||
"turn_count": self._turn_count,
|
||||
"last_dialectic_turn": self._last_dialectic_turn,
|
||||
"pending_result_fired_at": self._prefetch_result_fired_at,
|
||||
"empty_streak": self._dialectic_empty_streak,
|
||||
"effective_cadence": self._effective_cadence(),
|
||||
"thread_alive": thread_age is not None,
|
||||
"thread_age_seconds": thread_age,
|
||||
}
|
||||
|
||||
def _apply_reasoning_heuristic(self, base: str, query: str) -> str:
|
||||
"""Scale `base` up by query length, clamped at reasoning_level_cap.
|
||||
|
||||
Char-count heuristic: +1 at >=120 chars, +2 at >=400.
|
||||
"""
|
||||
if not self._reasoning_heuristic or not query:
|
||||
return base
|
||||
if base not in self._LEVEL_ORDER:
|
||||
return base
|
||||
n = len(query)
|
||||
if n < self._HEURISTIC_LENGTH_MEDIUM:
|
||||
bump = 0
|
||||
elif n < self._HEURISTIC_LENGTH_HIGH:
|
||||
bump = 1
|
||||
else:
|
||||
bump = 2
|
||||
base_idx = self._LEVEL_ORDER.index(base)
|
||||
cap_idx = self._LEVEL_ORDER.index(self._reasoning_level_cap)
|
||||
return self._LEVEL_ORDER[min(base_idx + bump, cap_idx)]
|
||||
|
||||
def _resolve_pass_level(self, pass_idx: int, query: str = "") -> str:
|
||||
"""Resolve reasoning level for a given pass index.
|
||||
|
||||
Uses dialecticDepthLevels if configured, otherwise proportional
|
||||
defaults relative to dialecticReasoningLevel.
|
||||
Precedence:
|
||||
1. dialecticDepthLevels (explicit per-pass) — wins absolutely
|
||||
2. _PROPORTIONAL_LEVELS table (depth>1 lighter-early passes)
|
||||
3. Base level = dialecticReasoningLevel, optionally scaled by the
|
||||
reasoning heuristic when the mapping falls through to 'base'
|
||||
"""
|
||||
if self._dialectic_depth_levels and pass_idx < len(self._dialectic_depth_levels):
|
||||
return self._dialectic_depth_levels[pass_idx]
|
||||
@@ -704,7 +880,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
base = (self._config.dialectic_reasoning_level if self._config else "low")
|
||||
mapping = self._PROPORTIONAL_LEVELS.get((self._dialectic_depth, pass_idx))
|
||||
if mapping is None or mapping == "base":
|
||||
return base
|
||||
return self._apply_reasoning_heuristic(base, query)
|
||||
return mapping
|
||||
|
||||
def _build_dialectic_prompt(self, pass_idx: int, prior_results: list[str], is_cold: bool) -> str:
|
||||
@@ -791,7 +967,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
break
|
||||
prompt = self._build_dialectic_prompt(i, results, is_cold)
|
||||
|
||||
level = self._resolve_pass_level(i)
|
||||
level = self._resolve_pass_level(i, query=query)
|
||||
logger.debug("Honcho dialectic depth %d: pass %d, level=%s, cold=%s",
|
||||
self._dialectic_depth, i, level, is_cold)
|
||||
|
||||
@@ -808,6 +984,29 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
return r
|
||||
return ""
|
||||
|
||||
# Prompts that carry no semantic signal — trivial acknowledgements, slash
|
||||
# commands, empty input. Skipping injection here saves tokens and prevents
|
||||
# stale user-model context from derailing one-word replies.
|
||||
_TRIVIAL_PROMPT_RE = re.compile(
|
||||
r'^(yes|no|ok|okay|sure|thanks|thank you|y|n|yep|nope|yeah|nah|'
|
||||
r'continue|go ahead|do it|proceed|got it|cool|nice|great|done|next|lgtm|k)$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _is_trivial_prompt(cls, text: str) -> bool:
|
||||
"""Return True if the prompt is too trivial to warrant context injection."""
|
||||
if not text:
|
||||
return True
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return True
|
||||
if stripped.startswith("/"):
|
||||
return True
|
||||
if cls._TRIVIAL_PROMPT_RE.match(stripped):
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
|
||||
"""Track turn count for cadence and injection_frequency logic."""
|
||||
self._turn_count = turn_number
|
||||
|
||||
@@ -460,17 +460,37 @@ def cmd_setup(args) -> None:
|
||||
pass # keep current
|
||||
|
||||
# --- 7b. Dialectic cadence ---
|
||||
current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "3")
|
||||
current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "2")
|
||||
print("\n Dialectic cadence:")
|
||||
print(" How often Honcho rebuilds its user model (LLM call on Honcho backend).")
|
||||
print(" 1 = every turn (aggressive), 3 = every 3 turns (recommended), 5+ = sparse.")
|
||||
print(" 1 = every turn, 2 = every other turn, 3+ = sparser.")
|
||||
print(" Recommended: 1-5.")
|
||||
new_dialectic = _prompt("Dialectic cadence", default=current_dialectic)
|
||||
try:
|
||||
val = int(new_dialectic)
|
||||
if val >= 1:
|
||||
hermes_host["dialecticCadence"] = val
|
||||
except (ValueError, TypeError):
|
||||
hermes_host["dialecticCadence"] = 3
|
||||
hermes_host["dialecticCadence"] = 2
|
||||
|
||||
# --- 7c. Dialectic reasoning level ---
|
||||
current_reasoning = (
|
||||
hermes_host.get("dialecticReasoningLevel")
|
||||
or cfg.get("dialecticReasoningLevel")
|
||||
or "low"
|
||||
)
|
||||
print("\n Dialectic reasoning level:")
|
||||
print(" Depth Honcho uses when synthesizing user context on auto-injected calls.")
|
||||
print(" minimal -- quick factual lookups")
|
||||
print(" low -- straightforward questions (default)")
|
||||
print(" medium -- multi-aspect synthesis")
|
||||
print(" high -- complex behavioral patterns")
|
||||
print(" max -- thorough audit-level analysis")
|
||||
new_reasoning = _prompt("Reasoning level", default=current_reasoning)
|
||||
if new_reasoning in ("minimal", "low", "medium", "high", "max"):
|
||||
hermes_host["dialecticReasoningLevel"] = new_reasoning
|
||||
else:
|
||||
hermes_host["dialecticReasoningLevel"] = "low"
|
||||
|
||||
# --- 8. Session strategy ---
|
||||
current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-session")
|
||||
@@ -636,8 +656,11 @@ def cmd_status(args) -> None:
|
||||
print(f" Recall mode: {hcfg.recall_mode}")
|
||||
print(f" Context budget: {hcfg.context_tokens or '(uncapped)'} tokens")
|
||||
raw = getattr(hcfg, "raw", None) or {}
|
||||
dialectic_cadence = raw.get("dialecticCadence") or 3
|
||||
dialectic_cadence = raw.get("dialecticCadence") or 1
|
||||
print(f" Dialectic cad: every {dialectic_cadence} turn{'s' if dialectic_cadence != 1 else ''}")
|
||||
reasoning_cap = raw.get("reasoningLevelCap") or hcfg.reasoning_level_cap
|
||||
heuristic_on = "on" if hcfg.reasoning_heuristic else "off"
|
||||
print(f" Reasoning: base={hcfg.dialectic_reasoning_level}, cap={reasoning_cap}, heuristic={heuristic_on}")
|
||||
print(f" Observation: user(me={hcfg.user_observe_me},others={hcfg.user_observe_others}) ai(me={hcfg.ai_observe_me},others={hcfg.ai_observe_others})")
|
||||
print(f" Write freq: {hcfg.write_frequency}")
|
||||
|
||||
|
||||
@@ -251,6 +251,11 @@ class HonchoClientConfig:
|
||||
# matching dialectic_depth length. When None, uses proportional defaults
|
||||
# derived from dialectic_reasoning_level.
|
||||
dialectic_depth_levels: list[str] | None = None
|
||||
# When true, the auto-injected dialectic scales reasoning level up on
|
||||
# longer queries. See HonchoMemoryProvider for thresholds.
|
||||
reasoning_heuristic: bool = True
|
||||
# Ceiling for the heuristic-selected reasoning level.
|
||||
reasoning_level_cap: str = "high"
|
||||
# Honcho API limits — configurable for self-hosted instances
|
||||
# Max chars per message sent via add_messages() (Honcho cloud: 25000)
|
||||
message_max_chars: int = 25000
|
||||
@@ -446,6 +451,16 @@ class HonchoClientConfig:
|
||||
raw.get("dialecticDepthLevels"),
|
||||
depth=_parse_dialectic_depth(host_block.get("dialecticDepth"), raw.get("dialecticDepth")),
|
||||
),
|
||||
reasoning_heuristic=_resolve_bool(
|
||||
host_block.get("reasoningHeuristic"),
|
||||
raw.get("reasoningHeuristic"),
|
||||
default=True,
|
||||
),
|
||||
reasoning_level_cap=(
|
||||
host_block.get("reasoningLevelCap")
|
||||
or raw.get("reasoningLevelCap")
|
||||
or "high"
|
||||
),
|
||||
message_max_chars=int(
|
||||
host_block.get("messageMaxChars")
|
||||
or raw.get("messageMaxChars")
|
||||
|
||||
@@ -78,6 +78,7 @@ class HonchoSessionManager:
|
||||
honcho: Honcho | None = None,
|
||||
context_tokens: int | None = None,
|
||||
config: Any | None = None,
|
||||
runtime_user_peer_name: str | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the session manager.
|
||||
@@ -87,10 +88,12 @@ class HonchoSessionManager:
|
||||
context_tokens: Max tokens for context() calls (None = Honcho default).
|
||||
config: HonchoClientConfig from global config (provides peer_name, ai_peer,
|
||||
write_frequency, observation, etc.).
|
||||
runtime_user_peer_name: Gateway user identity for per-user memory scoping.
|
||||
"""
|
||||
self._honcho = honcho
|
||||
self._context_tokens = context_tokens
|
||||
self._config = config
|
||||
self._runtime_user_peer_name = runtime_user_peer_name
|
||||
self._cache: dict[str, HonchoSession] = {}
|
||||
self._peers_cache: dict[str, Any] = {}
|
||||
self._sessions_cache: dict[str, Any] = {}
|
||||
@@ -100,9 +103,11 @@ class HonchoSessionManager:
|
||||
self._write_frequency = write_frequency
|
||||
self._turn_counter: int = 0
|
||||
|
||||
# Prefetch caches: session_key → last result (consumed once per turn)
|
||||
# Prefetch cache: session_key → last context result (consumed once per turn).
|
||||
# Dialectic results are cached on the plugin side (HonchoMemoryProvider
|
||||
# ._prefetch_result) so session-start prewarm and turn-driven fires share
|
||||
# one source of truth; see __init__.py _do_session_init for the prewarm.
|
||||
self._context_cache: dict[str, dict] = {}
|
||||
self._dialectic_cache: dict[str, str] = {}
|
||||
self._prefetch_cache_lock = threading.Lock()
|
||||
self._dialectic_reasoning_level: str = (
|
||||
config.dialectic_reasoning_level if config else "low"
|
||||
@@ -272,8 +277,10 @@ class HonchoSessionManager:
|
||||
logger.debug("Local session cache hit: %s", key)
|
||||
return self._cache[key]
|
||||
|
||||
# Use peer names from global config when available
|
||||
if self._config and self._config.peer_name:
|
||||
# Gateway sessions should use the runtime user identity when available.
|
||||
if self._runtime_user_peer_name:
|
||||
user_peer_id = self._sanitize_id(self._runtime_user_peer_name)
|
||||
elif self._config and self._config.peer_name:
|
||||
user_peer_id = self._sanitize_id(self._config.peer_name)
|
||||
else:
|
||||
# Fallback: derive from session key
|
||||
@@ -499,8 +506,8 @@ class HonchoSessionManager:
|
||||
Query Honcho's dialectic endpoint about a peer.
|
||||
|
||||
Runs an LLM on Honcho's backend against the target peer's full
|
||||
representation. Higher latency than context() — call async via
|
||||
prefetch_dialectic() to avoid blocking the response.
|
||||
representation. Higher latency than context() — callers run this in
|
||||
a background thread (see HonchoMemoryProvider) to avoid blocking.
|
||||
|
||||
Args:
|
||||
session_key: The session key to query against.
|
||||
@@ -555,42 +562,6 @@ class HonchoSessionManager:
|
||||
logger.warning("Honcho dialectic query failed: %s", e)
|
||||
return ""
|
||||
|
||||
def prefetch_dialectic(self, session_key: str, query: str) -> None:
|
||||
"""
|
||||
Fire a dialectic_query in a background thread, caching the result.
|
||||
|
||||
Non-blocking. The result is available via pop_dialectic_result()
|
||||
on the next call (typically the following turn). Reasoning level
|
||||
is selected dynamically based on query complexity.
|
||||
|
||||
Args:
|
||||
session_key: The session key to query against.
|
||||
query: The user's current message, used as the query.
|
||||
"""
|
||||
def _run():
|
||||
result = self.dialectic_query(session_key, query)
|
||||
if result:
|
||||
self.set_dialectic_result(session_key, result)
|
||||
|
||||
t = threading.Thread(target=_run, name="honcho-dialectic-prefetch", daemon=True)
|
||||
t.start()
|
||||
|
||||
def set_dialectic_result(self, session_key: str, result: str) -> None:
|
||||
"""Store a prefetched dialectic result in a thread-safe way."""
|
||||
if not result:
|
||||
return
|
||||
with self._prefetch_cache_lock:
|
||||
self._dialectic_cache[session_key] = result
|
||||
|
||||
def pop_dialectic_result(self, session_key: str) -> str:
|
||||
"""
|
||||
Return and clear the cached dialectic result for this session.
|
||||
|
||||
Returns empty string if no result is ready yet.
|
||||
"""
|
||||
with self._prefetch_cache_lock:
|
||||
return self._dialectic_cache.pop(session_key, "")
|
||||
|
||||
def prefetch_context(self, session_key: str, user_message: str | None = None) -> None:
|
||||
"""
|
||||
Fire get_prefetch_context in a background thread, caching the result.
|
||||
|
||||
+14
-102
@@ -1306,31 +1306,6 @@ class AIAgent:
|
||||
try:
|
||||
_mem_provider_name = mem_config.get("provider", "") if mem_config else ""
|
||||
|
||||
# Auto-migrate: if Honcho was actively configured (enabled +
|
||||
# credentials) but memory.provider is not set, activate the
|
||||
# honcho plugin automatically. Just having the config file
|
||||
# is not enough — the user may have disabled Honcho or the
|
||||
# file may be from a different tool.
|
||||
if not _mem_provider_name:
|
||||
try:
|
||||
from plugins.memory.honcho.client import HonchoClientConfig as _HCC
|
||||
_hcfg = _HCC.from_global_config()
|
||||
if _hcfg.enabled and (_hcfg.api_key or _hcfg.base_url):
|
||||
_mem_provider_name = "honcho"
|
||||
# Persist so this only auto-migrates once
|
||||
try:
|
||||
from hermes_cli.config import load_config as _lc, save_config as _sc
|
||||
_cfg = _lc()
|
||||
_cfg.setdefault("memory", {})["provider"] = "honcho"
|
||||
_sc(_cfg)
|
||||
except Exception:
|
||||
pass
|
||||
if not self.quiet_mode:
|
||||
print(" ✓ Auto-migrated Honcho to memory provider plugin.")
|
||||
print(" Your config and data are preserved.\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _mem_provider_name:
|
||||
from agent.memory_manager import MemoryManager as _MemoryManager
|
||||
from plugins.memory import load_memory_provider as _load_mem
|
||||
@@ -1941,13 +1916,16 @@ class AIAgent:
|
||||
def _should_emit_quiet_tool_messages(self) -> bool:
|
||||
"""Return True when quiet-mode tool summaries should print directly.
|
||||
|
||||
When the caller provides ``tool_progress_callback`` (for example the CLI
|
||||
TUI or a gateway progress renderer), that callback owns progress display.
|
||||
Emitting quiet-mode summary lines here duplicates progress and leaks tool
|
||||
previews into flows that are expected to stay silent, such as
|
||||
``hermes chat -q``.
|
||||
Quiet mode is used by both the interactive CLI and embedded/library
|
||||
callers. The CLI may still want compact progress hints when no callback
|
||||
owns rendering. Embedded/library callers, on the other hand, expect
|
||||
quiet mode to be truly silent.
|
||||
"""
|
||||
return self.quiet_mode and not self.tool_progress_callback
|
||||
return (
|
||||
self.quiet_mode
|
||||
and not self.tool_progress_callback
|
||||
and getattr(self, "platform", "") == "cli"
|
||||
)
|
||||
|
||||
def _emit_status(self, message: str) -> None:
|
||||
"""Emit a lifecycle status message to both CLI and gateway channels.
|
||||
@@ -8347,7 +8325,7 @@ class AIAgent:
|
||||
elif self._context_engine_tool_names and function_name in self._context_engine_tool_names:
|
||||
# Context engine tools (lcm_grep, lcm_describe, lcm_expand, etc.)
|
||||
spinner = None
|
||||
if self.quiet_mode and not self.tool_progress_callback:
|
||||
if self._should_emit_quiet_tool_messages():
|
||||
face = random.choice(KawaiiSpinner.get_waiting_faces())
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
preview = _build_tool_preview(function_name, function_args) or function_name
|
||||
@@ -8365,7 +8343,7 @@ class AIAgent:
|
||||
cute_msg = _get_cute_tool_message_impl(function_name, function_args, tool_duration, result=_ce_result)
|
||||
if spinner:
|
||||
spinner.stop(cute_msg)
|
||||
elif self.quiet_mode:
|
||||
elif self._should_emit_quiet_tool_messages():
|
||||
self._vprint(f" {cute_msg}")
|
||||
elif self._memory_manager and self._memory_manager.has_tool(function_name):
|
||||
# Memory provider tools (hindsight_retain, honcho_search, etc.)
|
||||
@@ -9829,30 +9807,9 @@ class AIAgent:
|
||||
prompt_tokens = canonical_usage.prompt_tokens
|
||||
completion_tokens = canonical_usage.output_tokens
|
||||
total_tokens = canonical_usage.total_tokens
|
||||
# For the context compressor, subtract reasoning
|
||||
# tokens from completion_tokens. Reasoning tokens
|
||||
# (from completion_tokens_details.reasoning_tokens)
|
||||
# are internal chain-of-thought that the provider
|
||||
# bills as output but that do NOT appear in the
|
||||
# context window on the next turn. Including them
|
||||
# inflates last_completion_tokens and causes
|
||||
# premature compression for thinking models
|
||||
# (GLM-5.1, QwQ, DeepSeek-R1). Fixes #12026.
|
||||
_reasoning_toks = canonical_usage.reasoning_tokens
|
||||
_content_completion = max(
|
||||
0, completion_tokens - _reasoning_toks
|
||||
)
|
||||
if _reasoning_toks > 0:
|
||||
logger.info(
|
||||
"Reasoning tokens excluded from compression: "
|
||||
"%d reasoning of %d total completion → "
|
||||
"%d content tokens for compressor",
|
||||
_reasoning_toks, completion_tokens,
|
||||
_content_completion,
|
||||
)
|
||||
usage_dict = {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": _content_completion,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
self.context_compressor.update_from_response(usage_dict)
|
||||
@@ -9948,44 +9905,6 @@ class AIAgent:
|
||||
hit_pct = (cached / prompt * 100) if prompt > 0 else 0
|
||||
if not self.quiet_mode:
|
||||
self._vprint(f"{self.log_prefix} 💾 Cache: {cached:,}/{prompt:,} tokens ({hit_pct:.0f}% hit, {written:,} written)")
|
||||
else:
|
||||
# Provider returned no usage data (e.g. MiniMax via
|
||||
# OpenRouter ignores stream_options.include_usage).
|
||||
# Fall back to rough token estimation so sessions
|
||||
# don't permanently record 0/0 tokens. Fixes #12023.
|
||||
_est_in = estimate_messages_tokens_rough(messages)
|
||||
_est_out = estimate_tokens_rough(
|
||||
(response.choices[0].message.content or "")
|
||||
if response.choices else ""
|
||||
)
|
||||
_est_total = _est_in + _est_out
|
||||
logger.warning(
|
||||
"No usage data in response for model=%s provider=%s "
|
||||
"— using rough estimates (in≈%d, out≈%d)",
|
||||
self.model, self.provider or "unknown",
|
||||
_est_in, _est_out,
|
||||
)
|
||||
self.context_compressor.update_from_response({
|
||||
"prompt_tokens": _est_in,
|
||||
"completion_tokens": _est_out,
|
||||
"total_tokens": _est_total,
|
||||
})
|
||||
self.session_prompt_tokens += _est_in
|
||||
self.session_completion_tokens += _est_out
|
||||
self.session_total_tokens += _est_total
|
||||
self.session_api_calls += 1
|
||||
self.session_input_tokens += _est_in
|
||||
self.session_output_tokens += _est_out
|
||||
if self._session_db and self.session_id:
|
||||
try:
|
||||
self._session_db.update_token_counts(
|
||||
self.session_id,
|
||||
input_tokens=_est_in,
|
||||
output_tokens=_est_out,
|
||||
model=self.model,
|
||||
)
|
||||
except Exception:
|
||||
pass # never block the agent loop
|
||||
|
||||
has_retried_429 = False # Reset on success
|
||||
# Clear Nous rate limit state on successful request —
|
||||
@@ -11268,17 +11187,10 @@ class AIAgent:
|
||||
self._last_content_tools_all_housekeeping = _all_housekeeping
|
||||
if _all_housekeeping and self._has_stream_consumers():
|
||||
self._mute_post_response = True
|
||||
elif self.quiet_mode:
|
||||
elif self._should_emit_quiet_tool_messages():
|
||||
clean = self._strip_think_blocks(turn_content).strip()
|
||||
if clean:
|
||||
relayed = False
|
||||
if (
|
||||
self.tool_progress_callback
|
||||
and getattr(self, "platform", "") == "tui"
|
||||
):
|
||||
relayed = True
|
||||
if not relayed:
|
||||
self._vprint(f" ┊ 💬 {clean}")
|
||||
self._vprint(f" ┊ 💬 {clean}")
|
||||
|
||||
# Pop thinking-only prefill message(s) before appending
|
||||
# (tool-call path — same rationale as the final-response path).
|
||||
|
||||
@@ -215,6 +215,7 @@ AUTHOR_MAP = {
|
||||
"ziliangpeng@users.noreply.github.com": "ziliangpeng",
|
||||
"centripetal-star@users.noreply.github.com": "centripetal-star",
|
||||
"LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
||||
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
||||
"Lubrsy706@users.noreply.github.com": "Lubrsy706",
|
||||
"niyant@spicefi.xyz": "spniyant",
|
||||
"olafthiele@gmail.com": "olafthiele",
|
||||
|
||||
@@ -229,6 +229,14 @@ async function startSocket() {
|
||||
|
||||
// Check allowlist for messages from others (resolve LID ↔ phone aliases)
|
||||
if (!msg.key.fromMe && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
|
||||
try {
|
||||
console.log(JSON.stringify({
|
||||
event: 'ignored',
|
||||
reason: 'allowlist_mismatch',
|
||||
chatId,
|
||||
senderId,
|
||||
}));
|
||||
} catch {}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -971,8 +971,6 @@ class TestHonchoCadenceTracking:
|
||||
class FakeManager:
|
||||
def prefetch_context(self, key, query=None):
|
||||
pass
|
||||
def prefetch_dialectic(self, key, query):
|
||||
pass
|
||||
|
||||
p._manager = FakeManager()
|
||||
|
||||
|
||||
@@ -208,34 +208,81 @@ class TestMem0UserIdScoping:
|
||||
|
||||
|
||||
class TestHonchoUserIdScoping:
|
||||
"""Verify Honcho plugin uses gateway user_id for peer_name when provided."""
|
||||
"""Verify Honcho plugin keeps runtime user scoping separate from config peer_name."""
|
||||
|
||||
def test_gateway_user_id_overrides_peer_name(self):
|
||||
"""When user_id is in kwargs and no explicit peer_name, user_id should be used."""
|
||||
def test_gateway_user_id_is_passed_as_runtime_peer(self):
|
||||
"""Gateway user_id should scope Honcho sessions without mutating config peer_name."""
|
||||
from plugins.memory.honcho import HonchoMemoryProvider
|
||||
|
||||
provider = HonchoMemoryProvider()
|
||||
|
||||
# Create a mock config with NO explicit peer_name
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.enabled = True
|
||||
mock_cfg.api_key = "test-key"
|
||||
mock_cfg.base_url = None
|
||||
mock_cfg.peer_name = "" # No explicit peer_name — user_id should fill it
|
||||
mock_cfg.recall_mode = "tools" # Use tools mode to defer session init
|
||||
mock_cfg.peer_name = "static-user"
|
||||
mock_cfg.recall_mode = "context"
|
||||
mock_cfg.context_tokens = None
|
||||
mock_cfg.raw = {}
|
||||
mock_cfg.dialectic_depth = 1
|
||||
mock_cfg.dialectic_depth_levels = None
|
||||
mock_cfg.init_on_session_start = False
|
||||
mock_cfg.ai_peer = "hermes"
|
||||
mock_cfg.resolve_session_name.return_value = "test-sess"
|
||||
mock_cfg.session_strategy = "shared"
|
||||
|
||||
with patch(
|
||||
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
||||
return_value=mock_cfg,
|
||||
):
|
||||
), patch(
|
||||
"plugins.memory.honcho.client.get_honcho_client",
|
||||
return_value=MagicMock(),
|
||||
), patch(
|
||||
"plugins.memory.honcho.session.HonchoSessionManager",
|
||||
) as mock_manager_cls:
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_or_create.return_value = MagicMock(messages=[])
|
||||
mock_manager_cls.return_value = mock_manager
|
||||
provider.initialize(
|
||||
session_id="test-sess",
|
||||
user_id="discord_user_789",
|
||||
platform="discord",
|
||||
)
|
||||
|
||||
# The config's peer_name should have been overridden with the user_id
|
||||
assert mock_cfg.peer_name == "discord_user_789"
|
||||
assert mock_cfg.peer_name == "static-user"
|
||||
assert mock_manager_cls.call_args.kwargs["runtime_user_peer_name"] == "discord_user_789"
|
||||
|
||||
def test_session_manager_prefers_runtime_user_id_over_config_peer_name(self):
|
||||
"""Session manager should isolate gateway users even when config peer_name is static."""
|
||||
from plugins.memory.honcho.session import HonchoSessionManager
|
||||
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.peer_name = "static-user"
|
||||
mock_cfg.ai_peer = "hermes"
|
||||
mock_cfg.write_frequency = "sync"
|
||||
mock_cfg.dialectic_reasoning_level = "low"
|
||||
mock_cfg.dialectic_dynamic = True
|
||||
mock_cfg.dialectic_max_chars = 600
|
||||
mock_cfg.observation_mode = "directional"
|
||||
mock_cfg.user_observe_me = True
|
||||
mock_cfg.user_observe_others = True
|
||||
mock_cfg.ai_observe_me = True
|
||||
mock_cfg.ai_observe_others = True
|
||||
|
||||
manager = HonchoSessionManager(
|
||||
honcho=MagicMock(),
|
||||
config=mock_cfg,
|
||||
runtime_user_peer_name="discord_user_789",
|
||||
)
|
||||
|
||||
with patch.object(manager, "_get_or_create_peer", return_value=MagicMock()), patch.object(
|
||||
manager,
|
||||
"_get_or_create_honcho_session",
|
||||
return_value=(MagicMock(), []),
|
||||
):
|
||||
session = manager.get_or_create("discord:channel-1")
|
||||
|
||||
assert session.user_peer_id == "discord_user_789"
|
||||
|
||||
def test_no_user_id_preserves_config_peer_name(self):
|
||||
"""Without user_id, the config peer_name should be preserved."""
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_gquota_uses_chat_console_when_tui_is_live():
|
||||
from agent.google_oauth import GoogleOAuthError
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.console = MagicMock()
|
||||
cli._app = object()
|
||||
|
||||
live_console = MagicMock()
|
||||
|
||||
with patch("cli.ChatConsole", return_value=live_console), \
|
||||
patch("agent.google_oauth.get_valid_access_token", side_effect=GoogleOAuthError("No Google OAuth credentials found")), \
|
||||
patch("agent.google_oauth.load_credentials", return_value=None), \
|
||||
patch("agent.google_code_assist.retrieve_user_quota"):
|
||||
cli._handle_gquota_command("/gquota")
|
||||
|
||||
assert live_console.print.call_count == 2
|
||||
cli.console.print.assert_not_called()
|
||||
@@ -33,6 +33,20 @@ class TestCLIQuickCommands:
|
||||
printed = self._printed_plain(cli.console.print.call_args[0][0])
|
||||
assert printed == "daily-note"
|
||||
|
||||
def test_exec_command_uses_chat_console_when_tui_is_live(self):
|
||||
cli = self._make_cli({"dn": {"type": "exec", "command": "echo daily-note"}})
|
||||
cli._app = object()
|
||||
live_console = MagicMock()
|
||||
|
||||
with patch("cli.ChatConsole", return_value=live_console):
|
||||
result = cli.process_command("/dn")
|
||||
|
||||
assert result is True
|
||||
live_console.print.assert_called_once()
|
||||
printed = self._printed_plain(live_console.print.call_args[0][0])
|
||||
assert printed == "daily-note"
|
||||
cli.console.print.assert_not_called()
|
||||
|
||||
def test_exec_command_stderr_shown_on_no_stdout(self):
|
||||
cli = self._make_cli({"err": {"type": "exec", "command": "echo error >&2"}})
|
||||
result = cli.process_command("/err")
|
||||
|
||||
@@ -1175,6 +1175,180 @@ class TestBuildJobPromptSilentHint:
|
||||
assert system_pos < prompt_pos
|
||||
|
||||
|
||||
class TestParseWakeGate:
|
||||
"""Unit tests for _parse_wake_gate — pure function, no side effects."""
|
||||
|
||||
def test_empty_output_wakes(self):
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate("") is True
|
||||
assert _parse_wake_gate(None) is True
|
||||
|
||||
def test_whitespace_only_wakes(self):
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate(" \n\n \t\n") is True
|
||||
|
||||
def test_non_json_last_line_wakes(self):
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate("hello world") is True
|
||||
assert _parse_wake_gate("line 1\nline 2\nplain text") is True
|
||||
|
||||
def test_json_non_dict_wakes(self):
|
||||
"""Bare arrays, numbers, strings must not be interpreted as a gate."""
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate("[1, 2, 3]") is True
|
||||
assert _parse_wake_gate("42") is True
|
||||
assert _parse_wake_gate('"wakeAgent"') is True
|
||||
|
||||
def test_wake_gate_false_skips(self):
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate('{"wakeAgent": false}') is False
|
||||
|
||||
def test_wake_gate_true_wakes(self):
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate('{"wakeAgent": true}') is True
|
||||
|
||||
def test_wake_gate_missing_wakes(self):
|
||||
"""A JSON dict without a wakeAgent key defaults to waking."""
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate('{"data": {"foo": "bar"}}') is True
|
||||
|
||||
def test_non_boolean_false_still_wakes(self):
|
||||
"""Only strict ``False`` skips — truthy/falsy shortcuts are too risky."""
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
assert _parse_wake_gate('{"wakeAgent": 0}') is True
|
||||
assert _parse_wake_gate('{"wakeAgent": null}') is True
|
||||
assert _parse_wake_gate('{"wakeAgent": ""}') is True
|
||||
|
||||
def test_only_last_non_empty_line_parsed(self):
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
multi = 'some log output\nmore output\n{"wakeAgent": false}'
|
||||
assert _parse_wake_gate(multi) is False
|
||||
|
||||
def test_trailing_blank_lines_ignored(self):
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
multi = '{"wakeAgent": false}\n\n\n'
|
||||
assert _parse_wake_gate(multi) is False
|
||||
|
||||
def test_non_last_json_line_does_not_gate(self):
|
||||
"""A JSON gate on an earlier line with plain text after it does NOT trigger."""
|
||||
from cron.scheduler import _parse_wake_gate
|
||||
multi = '{"wakeAgent": false}\nactually this is the real output'
|
||||
assert _parse_wake_gate(multi) is True
|
||||
|
||||
|
||||
class TestRunJobWakeGate:
|
||||
"""Integration tests for run_job wake-gate short-circuit."""
|
||||
|
||||
def _make_job(self, name="wake-gate-test", script="check.py"):
|
||||
"""Minimal valid cron job dict for run_job."""
|
||||
return {
|
||||
"id": f"job_{name}",
|
||||
"name": name,
|
||||
"prompt": "Do a thing",
|
||||
"schedule": "*/5 * * * *",
|
||||
"script": script,
|
||||
}
|
||||
|
||||
def test_wake_false_skips_agent_and_returns_silent(self, caplog):
|
||||
"""When _run_job_script output ends with {wakeAgent: false}, the agent
|
||||
is not invoked and run_job returns the SILENT marker so delivery is
|
||||
suppressed."""
|
||||
from cron.scheduler import SILENT_MARKER
|
||||
import cron.scheduler as scheduler
|
||||
|
||||
with patch.object(scheduler, "_run_job_script",
|
||||
return_value=(True, '{"wakeAgent": false}')), \
|
||||
patch("run_agent.AIAgent") as agent_cls:
|
||||
success, doc, final, err = scheduler.run_job(self._make_job())
|
||||
|
||||
assert success is True
|
||||
assert err is None
|
||||
assert final == SILENT_MARKER
|
||||
assert "Script gate returned `wakeAgent=false`" in doc
|
||||
agent_cls.assert_not_called()
|
||||
|
||||
def test_wake_true_runs_agent_with_injected_output(self):
|
||||
"""When the script returns {wakeAgent: true, data: ...}, the agent is
|
||||
invoked and the data line still shows up in the prompt."""
|
||||
import cron.scheduler as scheduler
|
||||
|
||||
script_output = '{"wakeAgent": true, "data": {"new": 3}}'
|
||||
agent = MagicMock()
|
||||
agent.run_conversation = MagicMock(return_value={
|
||||
"final_response": "ok", "messages": []
|
||||
})
|
||||
with patch.object(scheduler, "_run_job_script",
|
||||
return_value=(True, script_output)), \
|
||||
patch("run_agent.AIAgent", return_value=agent) as agent_cls:
|
||||
success, doc, final, err = scheduler.run_job(self._make_job())
|
||||
|
||||
agent_cls.assert_called_once()
|
||||
# The script output should be visible in the prompt passed to
|
||||
# run_conversation.
|
||||
call_kwargs = agent.run_conversation.call_args
|
||||
prompt_arg = call_kwargs.args[0] if call_kwargs.args else call_kwargs.kwargs.get("user_message", "")
|
||||
assert script_output in prompt_arg
|
||||
assert success is True
|
||||
assert err is None
|
||||
|
||||
def test_script_runs_only_once_on_wake(self):
|
||||
"""Wake-true path must not re-run the script inside _build_job_prompt
|
||||
(script would execute twice otherwise, wasting work and risking
|
||||
double-side-effects)."""
|
||||
import cron.scheduler as scheduler
|
||||
|
||||
call_count = 0
|
||||
def _script_stub(path):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return (True, "regular output")
|
||||
|
||||
agent = MagicMock()
|
||||
agent.run_conversation = MagicMock(return_value={
|
||||
"final_response": "ok", "messages": []
|
||||
})
|
||||
with patch.object(scheduler, "_run_job_script", side_effect=_script_stub), \
|
||||
patch("run_agent.AIAgent", return_value=agent):
|
||||
scheduler.run_job(self._make_job())
|
||||
|
||||
assert call_count == 1, f"script ran {call_count}x, expected exactly 1"
|
||||
|
||||
def test_script_failure_does_not_trigger_gate(self):
|
||||
"""If _run_job_script returns success=False, the gate is NOT evaluated
|
||||
and the agent still runs (the failure is reported as context)."""
|
||||
import cron.scheduler as scheduler
|
||||
|
||||
# Malicious or broken script whose stderr happens to contain the
|
||||
# gate JSON — we must NOT honor it because ran_ok is False.
|
||||
agent = MagicMock()
|
||||
agent.run_conversation = MagicMock(return_value={
|
||||
"final_response": "ok", "messages": []
|
||||
})
|
||||
with patch.object(scheduler, "_run_job_script",
|
||||
return_value=(False, '{"wakeAgent": false}')), \
|
||||
patch("run_agent.AIAgent", return_value=agent) as agent_cls:
|
||||
success, doc, final, err = scheduler.run_job(self._make_job())
|
||||
|
||||
agent_cls.assert_called_once() # Agent DID wake despite the gate-like text
|
||||
|
||||
def test_no_script_path_runs_agent_normally(self):
|
||||
"""Regression: jobs without a script still work."""
|
||||
import cron.scheduler as scheduler
|
||||
|
||||
agent = MagicMock()
|
||||
agent.run_conversation = MagicMock(return_value={
|
||||
"final_response": "ok", "messages": []
|
||||
})
|
||||
job = self._make_job(script=None)
|
||||
job.pop("script", None)
|
||||
with patch.object(scheduler, "_run_job_script") as script_fn, \
|
||||
patch("run_agent.AIAgent", return_value=agent) as agent_cls:
|
||||
scheduler.run_job(job)
|
||||
|
||||
script_fn.assert_not_called()
|
||||
agent_cls.assert_called_once()
|
||||
|
||||
|
||||
class TestBuildJobPromptMissingSkill:
|
||||
"""Verify that a missing skill logs a warning and does not crash the job."""
|
||||
|
||||
|
||||
@@ -502,11 +502,13 @@ class TestSegmentBreakOnToolBoundary:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_segment_break_clears_failed_edit_fallback_state(self):
|
||||
"""A tool boundary after edit failure must not duplicate the next segment."""
|
||||
"""A tool boundary after edit failure must flush the undelivered tail
|
||||
without duplicating the prefix the user already saw (#8124)."""
|
||||
adapter = MagicMock()
|
||||
send_results = [
|
||||
SimpleNamespace(success=True, message_id="msg_1"),
|
||||
SimpleNamespace(success=True, message_id="msg_2"),
|
||||
SimpleNamespace(success=True, message_id="msg_3"),
|
||||
]
|
||||
adapter.send = AsyncMock(side_effect=send_results)
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=False, error="flood_control:6"))
|
||||
@@ -526,7 +528,60 @@ class TestSegmentBreakOnToolBoundary:
|
||||
await task
|
||||
|
||||
sent_texts = [call[1]["content"] for call in adapter.send.call_args_list]
|
||||
assert sent_texts == ["Hello ▉", "Next segment"]
|
||||
# The undelivered "world" tail must reach the user, and the next
|
||||
# segment must not duplicate "Hello" that was already visible.
|
||||
assert sent_texts == ["Hello ▉", "world", "Next segment"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_segment_break_after_mid_stream_edit_failure_preserves_tail(self):
|
||||
"""Regression for #8124: when an earlier edit succeeded but later edits
|
||||
fail (persistent flood control) and a tool boundary arrives before the
|
||||
fallback threshold is reached, the pre-boundary tail must still be
|
||||
delivered — not silently dropped by the segment reset."""
|
||||
adapter = MagicMock()
|
||||
# msg_1 for the initial partial, msg_2 for the flushed tail,
|
||||
# msg_3 for the post-boundary segment.
|
||||
send_results = [
|
||||
SimpleNamespace(success=True, message_id="msg_1"),
|
||||
SimpleNamespace(success=True, message_id="msg_2"),
|
||||
SimpleNamespace(success=True, message_id="msg_3"),
|
||||
]
|
||||
adapter.send = AsyncMock(side_effect=send_results)
|
||||
|
||||
# First two edits succeed, everything after fails with flood control
|
||||
# — simulating Telegram's "edit once then get rate-limited" pattern.
|
||||
edit_results = [
|
||||
SimpleNamespace(success=True), # "Hello world ▉" — succeeds
|
||||
SimpleNamespace(success=False, error="flood_control:6.0"), # "Hello world more ▉" — flood triggered
|
||||
SimpleNamespace(success=False, error="flood_control:6.0"), # finalize edit at segment break
|
||||
SimpleNamespace(success=False, error="flood_control:6.0"), # cursor-strip attempt
|
||||
]
|
||||
adapter.edit_message = AsyncMock(side_effect=edit_results + [edit_results[-1]] * 10)
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5, cursor=" ▉")
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
consumer.on_delta("Hello")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.08)
|
||||
consumer.on_delta(" world")
|
||||
await asyncio.sleep(0.08)
|
||||
consumer.on_delta(" more")
|
||||
await asyncio.sleep(0.08)
|
||||
consumer.on_delta(None) # tool boundary
|
||||
consumer.on_delta("Here is the tool result.")
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
sent_texts = [call[1]["content"] for call in adapter.send.call_args_list]
|
||||
# "more" must have been delivered, not dropped.
|
||||
all_text = " ".join(sent_texts)
|
||||
assert "more" in all_text, (
|
||||
f"Pre-boundary tail 'more' was silently dropped: sends={sent_texts}"
|
||||
)
|
||||
# Post-boundary text must also reach the user.
|
||||
assert "Here is the tool result." in all_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_message_id_enters_fallback_mode(self):
|
||||
|
||||
@@ -148,6 +148,70 @@ class TestDiscordTextBatching:
|
||||
await asyncio.sleep(0.25)
|
||||
adapter.handle_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shield_protects_handle_message_from_cancel(self):
|
||||
"""Regression guard: a follow-up chunk arriving while
|
||||
handle_message is mid-flight must NOT cancel the running
|
||||
dispatch. _enqueue_text_event fires prior_task.cancel() on
|
||||
every new chunk; without asyncio.shield around handle_message
|
||||
the cancel propagates into the agent's streaming request and
|
||||
aborts the response.
|
||||
"""
|
||||
adapter = _make_discord_adapter()
|
||||
|
||||
handle_started = asyncio.Event()
|
||||
release_handle = asyncio.Event()
|
||||
first_handle_cancelled = asyncio.Event()
|
||||
first_handle_completed = asyncio.Event()
|
||||
call_count = [0]
|
||||
|
||||
async def slow_handle(event):
|
||||
call_count[0] += 1
|
||||
# Only the first call (batch 1) is the one we're protecting.
|
||||
if call_count[0] == 1:
|
||||
handle_started.set()
|
||||
try:
|
||||
await release_handle.wait()
|
||||
first_handle_completed.set()
|
||||
except asyncio.CancelledError:
|
||||
first_handle_cancelled.set()
|
||||
raise
|
||||
# Second call (batch 2) returns immediately — not the subject
|
||||
# of this test.
|
||||
|
||||
adapter.handle_message = slow_handle
|
||||
|
||||
# Prime batch 1 and wait for it to land inside handle_message.
|
||||
adapter._enqueue_text_event(_make_event("batch 1", Platform.DISCORD))
|
||||
await asyncio.wait_for(handle_started.wait(), timeout=1.0)
|
||||
|
||||
# A new chunk arrives — _enqueue_text_event fires
|
||||
# prior_task.cancel() on batch 1's flush task, which is
|
||||
# currently awaiting inside handle_message.
|
||||
adapter._enqueue_text_event(_make_event("batch 2 follow-up", Platform.DISCORD))
|
||||
|
||||
# Let the cancel propagate.
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# CRITICAL ASSERTION: batch 1's handle_message must NOT have
|
||||
# been cancelled. Without asyncio.shield this assertion fails
|
||||
# because CancelledError propagates from the flush task's
|
||||
# `await self.handle_message(event)` into slow_handle.
|
||||
assert not first_handle_cancelled.is_set(), (
|
||||
"handle_message for batch 1 was cancelled by a follow-up "
|
||||
"chunk — asyncio.shield is missing or broken"
|
||||
)
|
||||
|
||||
# Release batch 1's handle_message and let it complete.
|
||||
release_handle.set()
|
||||
await asyncio.wait_for(first_handle_completed.wait(), timeout=1.0)
|
||||
assert first_handle_completed.is_set()
|
||||
|
||||
# Cleanup
|
||||
for task in list(adapter._pending_text_batch_tasks.values()):
|
||||
task.cancel()
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Matrix text batching
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests for agent-settings copy in the interactive setup wizard."""
|
||||
|
||||
from hermes_cli.setup import setup_agent_settings
|
||||
|
||||
|
||||
def test_setup_agent_settings_uses_displayed_max_iterations_value(tmp_path, monkeypatch, capsys):
|
||||
"""The helper text should match the value shown in the prompt."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
config = {
|
||||
"agent": {"max_turns": 90},
|
||||
"display": {"tool_progress": "all"},
|
||||
"compression": {"threshold": 0.50},
|
||||
"session_reset": {"mode": "both", "idle_minutes": 1440, "at_hour": 4},
|
||||
}
|
||||
|
||||
prompt_answers = iter(["60", "all", "0.5"])
|
||||
|
||||
monkeypatch.setattr("hermes_cli.setup.get_env_value", lambda key: "60" if key == "HERMES_MAX_ITERATIONS" else "")
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: next(prompt_answers))
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda *args, **kwargs: 4)
|
||||
monkeypatch.setattr("hermes_cli.setup.save_env_value", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr("hermes_cli.setup.save_config", lambda *args, **kwargs: None)
|
||||
|
||||
setup_agent_settings(config)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Press Enter to keep 60." in out
|
||||
assert "Default is 90" not in out
|
||||
@@ -460,10 +460,3 @@ class TestPrefetchCacheAccessors:
|
||||
assert mgr.pop_context_result("cli:test") == payload
|
||||
assert mgr.pop_context_result("cli:test") == {}
|
||||
|
||||
def test_set_and_pop_dialectic_result(self):
|
||||
mgr = _make_manager(write_frequency="turn")
|
||||
|
||||
mgr.set_dialectic_result("cli:test", "Resume with toolset cleanup")
|
||||
|
||||
assert mgr.pop_dialectic_result("cli:test") == "Resume with toolset cleanup"
|
||||
assert mgr.pop_dialectic_result("cli:test") == ""
|
||||
|
||||
@@ -26,6 +26,9 @@ class TestCmdStatus:
|
||||
write_frequency = "async"
|
||||
session_strategy = "per-session"
|
||||
context_tokens = 800
|
||||
dialectic_reasoning_level = "low"
|
||||
reasoning_level_cap = "high"
|
||||
reasoning_heuristic = True
|
||||
|
||||
def resolve_session_name(self):
|
||||
return "hermes"
|
||||
|
||||
@@ -568,15 +568,15 @@ class TestToolsModeInitBehavior:
|
||||
|
||||
with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \
|
||||
patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \
|
||||
patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \
|
||||
patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager) as mock_manager_cls, \
|
||||
patch("hermes_constants.get_hermes_home", return_value=MagicMock()):
|
||||
provider.initialize(session_id="test-session-001", **init_kwargs)
|
||||
|
||||
return provider, cfg
|
||||
return provider, cfg, mock_manager_cls
|
||||
|
||||
def test_tools_lazy_default(self):
|
||||
"""tools + initOnSessionStart=false → session NOT initialized after initialize()."""
|
||||
provider, _ = self._make_provider_with_config(
|
||||
provider, _, _ = self._make_provider_with_config(
|
||||
recall_mode="tools", init_on_session_start=False,
|
||||
)
|
||||
assert provider._session_initialized is False
|
||||
@@ -585,7 +585,7 @@ class TestToolsModeInitBehavior:
|
||||
|
||||
def test_tools_eager_init(self):
|
||||
"""tools + initOnSessionStart=true → session IS initialized after initialize()."""
|
||||
provider, _ = self._make_provider_with_config(
|
||||
provider, _, _ = self._make_provider_with_config(
|
||||
recall_mode="tools", init_on_session_start=True,
|
||||
)
|
||||
assert provider._session_initialized is True
|
||||
@@ -593,33 +593,34 @@ class TestToolsModeInitBehavior:
|
||||
|
||||
def test_tools_eager_prefetch_still_empty(self):
|
||||
"""tools mode with eager init still returns empty from prefetch() (no auto-injection)."""
|
||||
provider, _ = self._make_provider_with_config(
|
||||
provider, _, _ = self._make_provider_with_config(
|
||||
recall_mode="tools", init_on_session_start=True,
|
||||
)
|
||||
assert provider.prefetch("test query") == ""
|
||||
|
||||
def test_tools_lazy_prefetch_empty(self):
|
||||
"""tools mode with lazy init also returns empty from prefetch()."""
|
||||
provider, _ = self._make_provider_with_config(
|
||||
provider, _, _ = self._make_provider_with_config(
|
||||
recall_mode="tools", init_on_session_start=False,
|
||||
)
|
||||
assert provider.prefetch("test query") == ""
|
||||
|
||||
def test_explicit_peer_name_not_overridden_by_user_id(self):
|
||||
"""Explicit peerName in config must not be replaced by gateway user_id."""
|
||||
_, cfg = self._make_provider_with_config(
|
||||
_, cfg, _ = self._make_provider_with_config(
|
||||
recall_mode="tools", init_on_session_start=True,
|
||||
peer_name="Kathie", user_id="8439114563",
|
||||
)
|
||||
assert cfg.peer_name == "Kathie"
|
||||
|
||||
def test_user_id_used_when_no_peer_name(self):
|
||||
"""Gateway user_id is used as peer_name when no explicit peerName configured."""
|
||||
_, cfg = self._make_provider_with_config(
|
||||
"""Gateway user_id is passed separately from config peer_name."""
|
||||
_, cfg, mock_manager_cls = self._make_provider_with_config(
|
||||
recall_mode="tools", init_on_session_start=True,
|
||||
peer_name=None, user_id="8439114563",
|
||||
)
|
||||
assert cfg.peer_name == "8439114563"
|
||||
assert cfg.peer_name is None
|
||||
assert mock_manager_cls.call_args.kwargs["runtime_user_peer_name"] == "8439114563"
|
||||
|
||||
|
||||
class TestPerSessionMigrateGuard:
|
||||
@@ -815,6 +816,27 @@ class TestDialecticInputGuard:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _settle_prewarm(provider):
|
||||
"""Wait for the session-start prewarm dialectic thread, then return the
|
||||
provider to a clean 'nothing fired yet' state so cadence/first-turn/
|
||||
trivial-prompt tests can assert from a known baseline."""
|
||||
if provider._prefetch_thread:
|
||||
provider._prefetch_thread.join(timeout=3.0)
|
||||
with provider._prefetch_lock:
|
||||
provider._prefetch_result = ""
|
||||
provider._prefetch_result_fired_at = -999
|
||||
provider._prefetch_thread = None
|
||||
provider._prefetch_thread_started_at = 0.0
|
||||
provider._last_dialectic_turn = -999
|
||||
provider._dialectic_empty_streak = 0
|
||||
if getattr(provider, "_manager", None) is not None:
|
||||
try:
|
||||
provider._manager.dialectic_query.reset_mock()
|
||||
provider._manager.prefetch_context.reset_mock()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
class TestDialecticCadenceDefaults:
|
||||
"""Regression tests for dialectic_cadence default value."""
|
||||
|
||||
@@ -840,12 +862,15 @@ class TestDialecticCadenceDefaults:
|
||||
patch("hermes_constants.get_hermes_home", return_value=MagicMock()):
|
||||
provider.initialize(session_id="test-session-001")
|
||||
|
||||
_settle_prewarm(provider)
|
||||
return provider
|
||||
|
||||
def test_default_is_3(self):
|
||||
"""Default dialectic_cadence should be 3 to avoid per-turn LLM calls."""
|
||||
def test_unset_falls_back_to_1(self):
|
||||
"""Unset dialecticCadence falls back to 1 (every turn) for backwards
|
||||
compatibility with existing configs that predate the setting. The
|
||||
setup wizard writes 2 explicitly on new configs."""
|
||||
provider = self._make_provider()
|
||||
assert provider._dialectic_cadence == 3
|
||||
assert provider._dialectic_cadence == 1
|
||||
|
||||
def test_config_override(self):
|
||||
"""dialecticCadence from config overrides the default."""
|
||||
@@ -908,6 +933,7 @@ class TestDialecticDepth:
|
||||
patch("hermes_constants.get_hermes_home", return_value=MagicMock()):
|
||||
provider.initialize(session_id="test-session-001")
|
||||
|
||||
_settle_prewarm(provider)
|
||||
return provider
|
||||
|
||||
def test_default_depth_is_1(self):
|
||||
@@ -1027,46 +1053,6 @@ class TestDialecticDepth:
|
||||
assert provider._manager.dialectic_query.call_count == 2
|
||||
assert "Synthesis" in result
|
||||
|
||||
def test_first_turn_runs_dialectic_synchronously(self):
|
||||
"""First turn should fire the dialectic synchronously (cold start)."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
provider = self._make_provider(cfg_extra={"dialectic_depth": 1})
|
||||
provider._manager = MagicMock()
|
||||
provider._manager.dialectic_query.return_value = "cold start synthesis"
|
||||
provider._manager.get_prefetch_context.return_value = None
|
||||
provider._manager.pop_context_result.return_value = None
|
||||
provider._session_key = "test"
|
||||
provider._base_context_cache = "" # cold start
|
||||
provider._last_dialectic_turn = -999 # never fired
|
||||
|
||||
result = provider.prefetch("hello world")
|
||||
assert "cold start synthesis" in result
|
||||
assert provider._manager.dialectic_query.call_count == 1
|
||||
# After first-turn sync, _last_dialectic_turn should be updated
|
||||
assert provider._last_dialectic_turn != -999
|
||||
|
||||
def test_first_turn_dialectic_does_not_double_fire(self):
|
||||
"""After first-turn sync dialectic, queue_prefetch should skip (cadence)."""
|
||||
from unittest.mock import MagicMock
|
||||
provider = self._make_provider(cfg_extra={"dialectic_depth": 1})
|
||||
provider._manager = MagicMock()
|
||||
provider._manager.dialectic_query.return_value = "cold start synthesis"
|
||||
provider._manager.get_prefetch_context.return_value = None
|
||||
provider._manager.pop_context_result.return_value = None
|
||||
provider._session_key = "test"
|
||||
provider._base_context_cache = ""
|
||||
provider._last_dialectic_turn = -999
|
||||
provider._turn_count = 0
|
||||
|
||||
# First turn fires sync dialectic
|
||||
provider.prefetch("hello")
|
||||
assert provider._manager.dialectic_query.call_count == 1
|
||||
|
||||
# Now queue_prefetch on same turn should skip (cadence: 0 - 0 < 3)
|
||||
provider._manager.dialectic_query.reset_mock()
|
||||
provider.queue_prefetch("hello")
|
||||
assert provider._manager.dialectic_query.call_count == 0
|
||||
|
||||
def test_run_dialectic_depth_bails_early_on_strong_signal(self):
|
||||
"""Depth 2 skips pass 1 when pass 0 returns strong signal."""
|
||||
from unittest.mock import MagicMock
|
||||
@@ -1083,6 +1069,584 @@ class TestDialecticDepth:
|
||||
assert provider._manager.dialectic_query.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trivial-prompt heuristic + dialectic cadence silent-failure guards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTrivialPromptHeuristic:
|
||||
"""Trivial prompts ('ok', 'y', slash commands) must short-circuit injection."""
|
||||
|
||||
@staticmethod
|
||||
def _make_provider():
|
||||
from unittest.mock import patch, MagicMock
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
cfg = HonchoClientConfig(api_key="test-key", enabled=True, recall_mode="hybrid")
|
||||
provider = HonchoMemoryProvider()
|
||||
mock_manager = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_session.messages = []
|
||||
mock_manager.get_or_create.return_value = mock_session
|
||||
|
||||
with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \
|
||||
patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \
|
||||
patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \
|
||||
patch("hermes_constants.get_hermes_home", return_value=MagicMock()):
|
||||
provider.initialize(session_id="test-session-trivial")
|
||||
_settle_prewarm(provider)
|
||||
return provider
|
||||
|
||||
def test_classifier_catches_common_trivial_forms(self):
|
||||
for t in ("ok", "OK", " ok ", "y", "yes", "sure", "thanks", "lgtm", "/help", "", " "):
|
||||
assert HonchoMemoryProvider._is_trivial_prompt(t), f"expected trivial: {t!r}"
|
||||
|
||||
def test_classifier_lets_substantive_prompts_through(self):
|
||||
for t in ("hello world", "what's my name", "explain this", "ok so what's next"):
|
||||
assert not HonchoMemoryProvider._is_trivial_prompt(t), f"expected non-trivial: {t!r}"
|
||||
|
||||
def test_prefetch_skips_on_trivial_prompt(self):
|
||||
provider = self._make_provider()
|
||||
provider._session_key = "test"
|
||||
provider._base_context_cache = "cached base"
|
||||
provider._last_dialectic_turn = 0
|
||||
provider._turn_count = 5
|
||||
|
||||
assert provider.prefetch("ok") == ""
|
||||
assert provider.prefetch("/help") == ""
|
||||
# Dialectic should not have fired
|
||||
assert provider._manager.dialectic_query.call_count == 0
|
||||
|
||||
def test_queue_prefetch_skips_on_trivial_prompt(self):
|
||||
provider = self._make_provider()
|
||||
provider._session_key = "test"
|
||||
provider._turn_count = 10
|
||||
provider._last_dialectic_turn = -999 # would otherwise fire
|
||||
# initialize() pre-warms; clear call counts before the assertion.
|
||||
provider._manager.prefetch_context.reset_mock()
|
||||
provider._manager.dialectic_query.reset_mock()
|
||||
|
||||
provider.queue_prefetch("y")
|
||||
# Trivial prompts short-circuit both context refresh and dialectic fire.
|
||||
assert provider._manager.prefetch_context.call_count == 0
|
||||
assert provider._manager.dialectic_query.call_count == 0
|
||||
|
||||
|
||||
class TestDialecticCadenceAdvancesOnSuccess:
|
||||
"""Cadence tracker advances only when the dialectic call returns a
|
||||
non-empty result. Empty results (transient API error, sparse representation)
|
||||
must retry on the next eligible turn instead of waiting the full cadence."""
|
||||
|
||||
@staticmethod
|
||||
def _make_provider():
|
||||
from unittest.mock import patch, MagicMock
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
cfg = HonchoClientConfig(
|
||||
api_key="test-key", enabled=True, recall_mode="hybrid", dialectic_depth=1,
|
||||
)
|
||||
provider = HonchoMemoryProvider()
|
||||
mock_manager = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_session.messages = []
|
||||
mock_manager.get_or_create.return_value = mock_session
|
||||
|
||||
with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \
|
||||
patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \
|
||||
patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \
|
||||
patch("hermes_constants.get_hermes_home", return_value=MagicMock()):
|
||||
provider.initialize(session_id="test-session-retry")
|
||||
_settle_prewarm(provider)
|
||||
return provider
|
||||
|
||||
def test_empty_dialectic_result_does_not_advance_cadence(self):
|
||||
import time as _time
|
||||
provider = self._make_provider()
|
||||
provider._session_key = "test"
|
||||
provider._manager.dialectic_query.return_value = "" # silent failure
|
||||
provider._turn_count = 5
|
||||
provider._last_dialectic_turn = 0 # would fire (5 - 0 = 5 ≥ 3)
|
||||
|
||||
provider.queue_prefetch("hello")
|
||||
# wait for the background thread to settle
|
||||
if provider._prefetch_thread:
|
||||
provider._prefetch_thread.join(timeout=2.0)
|
||||
|
||||
# Dialectic call was attempted
|
||||
assert provider._manager.dialectic_query.call_count == 1
|
||||
# But cadence tracker did NOT advance — next turn should retry
|
||||
assert provider._last_dialectic_turn == 0
|
||||
|
||||
def test_non_empty_dialectic_result_advances_cadence(self):
|
||||
provider = self._make_provider()
|
||||
provider._session_key = "test"
|
||||
provider._manager.dialectic_query.return_value = "real synthesis output"
|
||||
provider._turn_count = 5
|
||||
provider._last_dialectic_turn = 0
|
||||
|
||||
provider.queue_prefetch("hello")
|
||||
if provider._prefetch_thread:
|
||||
provider._prefetch_thread.join(timeout=2.0)
|
||||
|
||||
assert provider._last_dialectic_turn == 5
|
||||
|
||||
def test_in_flight_thread_is_not_stacked(self):
|
||||
import threading as _threading
|
||||
import time as _time
|
||||
provider = self._make_provider()
|
||||
provider._session_key = "test"
|
||||
provider._turn_count = 10
|
||||
provider._last_dialectic_turn = 0
|
||||
|
||||
# Simulate a prior thread still running (fresh, not stale)
|
||||
hold = _threading.Event()
|
||||
|
||||
def _block():
|
||||
hold.wait(timeout=5.0)
|
||||
|
||||
fresh = _threading.Thread(target=_block, daemon=True)
|
||||
fresh.start()
|
||||
provider._prefetch_thread = fresh
|
||||
provider._prefetch_thread_started_at = _time.monotonic() # fresh start
|
||||
|
||||
provider.queue_prefetch("hello")
|
||||
# Should have short-circuited — no new dialectic call
|
||||
assert provider._manager.dialectic_query.call_count == 0
|
||||
hold.set()
|
||||
fresh.join(timeout=2.0)
|
||||
|
||||
|
||||
class TestSessionStartDialecticPrewarm:
|
||||
"""Session-start prewarm fires a depth-aware dialectic whose result is
|
||||
consumed by turn 1 — no duplicate .chat() and no dead-cache orphaning."""
|
||||
|
||||
@staticmethod
|
||||
def _make_provider(cfg_extra=None, dialectic_result="prewarm synthesis"):
|
||||
from unittest.mock import patch, MagicMock
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
defaults = dict(api_key="test-key", enabled=True, recall_mode="hybrid")
|
||||
if cfg_extra:
|
||||
defaults.update(cfg_extra)
|
||||
cfg = HonchoClientConfig(**defaults)
|
||||
provider = HonchoMemoryProvider()
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_or_create.return_value = MagicMock(messages=[])
|
||||
mock_manager.get_prefetch_context.return_value = None
|
||||
mock_manager.pop_context_result.return_value = None
|
||||
mock_manager.dialectic_query.return_value = dialectic_result
|
||||
|
||||
with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \
|
||||
patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \
|
||||
patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \
|
||||
patch("hermes_constants.get_hermes_home", return_value=MagicMock()):
|
||||
provider.initialize(session_id="test-prewarm")
|
||||
return provider
|
||||
|
||||
def test_prewarm_populates_prefetch_result(self):
|
||||
p = self._make_provider()
|
||||
# Wait for prewarm thread to land
|
||||
if p._prefetch_thread:
|
||||
p._prefetch_thread.join(timeout=3.0)
|
||||
with p._prefetch_lock:
|
||||
assert p._prefetch_result == "prewarm synthesis"
|
||||
assert p._last_dialectic_turn == 0
|
||||
|
||||
def test_turn1_consumes_prewarm_without_duplicate_dialectic(self):
|
||||
"""With prewarm result already in _prefetch_result, turn 1 prefetch
|
||||
should NOT fire another dialectic."""
|
||||
p = self._make_provider()
|
||||
if p._prefetch_thread:
|
||||
p._prefetch_thread.join(timeout=3.0)
|
||||
p._manager.dialectic_query.reset_mock()
|
||||
p._session_key = "test-prewarm"
|
||||
p._base_context_cache = ""
|
||||
p._turn_count = 1
|
||||
|
||||
result = p.prefetch("hello world")
|
||||
assert "prewarm synthesis" in result
|
||||
# The sync first-turn path must NOT have fired another .chat()
|
||||
assert p._manager.dialectic_query.call_count == 0
|
||||
|
||||
def test_turn1_falls_back_to_sync_when_prewarm_missing(self):
|
||||
"""If the prewarm produced nothing (empty graph, API blip), turn 1
|
||||
still fires its own sync dialectic."""
|
||||
p = self._make_provider(dialectic_result="") # prewarm returns empty
|
||||
if p._prefetch_thread:
|
||||
p._prefetch_thread.join(timeout=3.0)
|
||||
with p._prefetch_lock:
|
||||
assert p._prefetch_result == "" # prewarm landed nothing
|
||||
# Switch dialectic_query to return something on the sync first-turn call
|
||||
p._manager.dialectic_query.return_value = "sync recovery"
|
||||
p._manager.dialectic_query.reset_mock()
|
||||
p._session_key = "test-prewarm"
|
||||
p._base_context_cache = ""
|
||||
p._turn_count = 1
|
||||
|
||||
result = p.prefetch("hello world")
|
||||
assert "sync recovery" in result
|
||||
assert p._manager.dialectic_query.call_count == 1
|
||||
|
||||
|
||||
class TestDialecticLiveness:
|
||||
"""Liveness + observability: stale-thread recovery, stale-result discard,
|
||||
empty-streak backoff, and the snapshot method used for diagnostics."""
|
||||
|
||||
@staticmethod
|
||||
def _make_provider(cfg_extra=None):
|
||||
from unittest.mock import patch, MagicMock
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
defaults = dict(api_key="test-key", enabled=True, recall_mode="hybrid", timeout=2.0)
|
||||
if cfg_extra:
|
||||
defaults.update(cfg_extra)
|
||||
cfg = HonchoClientConfig(**defaults)
|
||||
provider = HonchoMemoryProvider()
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_or_create.return_value = MagicMock(messages=[])
|
||||
mock_manager.get_prefetch_context.return_value = None
|
||||
mock_manager.pop_context_result.return_value = None
|
||||
mock_manager.dialectic_query.return_value = "" # default: silent
|
||||
|
||||
with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \
|
||||
patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \
|
||||
patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \
|
||||
patch("hermes_constants.get_hermes_home", return_value=MagicMock()):
|
||||
provider.initialize(session_id="test-liveness")
|
||||
_settle_prewarm(provider)
|
||||
return provider
|
||||
|
||||
def test_stale_thread_is_treated_as_dead(self):
|
||||
"""A thread older than timeout × multiplier no longer blocks new fires."""
|
||||
import threading as _threading
|
||||
p = self._make_provider()
|
||||
p._session_key = "test"
|
||||
p._turn_count = 10
|
||||
p._last_dialectic_turn = 0
|
||||
p._manager.dialectic_query.return_value = "fresh synthesis"
|
||||
|
||||
# Plant an alive thread with an old timestamp (stale)
|
||||
hold = _threading.Event()
|
||||
stuck = _threading.Thread(target=lambda: hold.wait(timeout=10.0), daemon=True)
|
||||
stuck.start()
|
||||
p._prefetch_thread = stuck
|
||||
# timeout=2.0, multiplier=2.0, so anything older than 4s is stale
|
||||
p._prefetch_thread_started_at = 0.0 # very old (1970 monotonic baseline)
|
||||
|
||||
p.queue_prefetch("hello")
|
||||
# New thread should have been spawned since stuck one is stale
|
||||
assert p._prefetch_thread is not stuck, "stale thread must be recycled"
|
||||
if p._prefetch_thread:
|
||||
p._prefetch_thread.join(timeout=2.0)
|
||||
assert p._manager.dialectic_query.call_count == 1
|
||||
hold.set()
|
||||
stuck.join(timeout=2.0)
|
||||
|
||||
def test_stale_pending_result_is_discarded_on_read(self):
|
||||
"""A pending dialectic result from many turns ago is discarded
|
||||
instead of injected against a fresh conversational pivot."""
|
||||
p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 2}})
|
||||
p._session_key = "test"
|
||||
p._base_context_cache = "base ctx"
|
||||
with p._prefetch_lock:
|
||||
p._prefetch_result = "ancient synthesis"
|
||||
p._prefetch_result_fired_at = 1
|
||||
# cadence=2, multiplier=2 → stale after 4 turns since fire
|
||||
p._turn_count = 10
|
||||
p._last_dialectic_turn = 1 # prevents sync first-turn path
|
||||
|
||||
result = p.prefetch("what's new")
|
||||
assert "ancient synthesis" not in result, "stale pending must be discarded"
|
||||
# Cache slot cleared
|
||||
with p._prefetch_lock:
|
||||
assert p._prefetch_result == ""
|
||||
assert p._prefetch_result_fired_at == -999
|
||||
|
||||
def test_fresh_pending_result_is_kept(self):
|
||||
"""A pending result within the staleness window is injected normally."""
|
||||
p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 3}})
|
||||
p._session_key = "test"
|
||||
p._base_context_cache = ""
|
||||
with p._prefetch_lock:
|
||||
p._prefetch_result = "recent synthesis"
|
||||
p._prefetch_result_fired_at = 8
|
||||
p._turn_count = 9 # 1 turn since fire, well within cadence × 2 = 6
|
||||
p._last_dialectic_turn = 8
|
||||
|
||||
result = p.prefetch("what's new")
|
||||
assert "recent synthesis" in result
|
||||
|
||||
def test_empty_streak_widens_effective_cadence(self):
|
||||
"""After N empty returns, the gate waits cadence + N turns."""
|
||||
p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 1}})
|
||||
p._dialectic_empty_streak = 3
|
||||
# cadence=1, streak=3 → effective = 4
|
||||
assert p._effective_cadence() == 4
|
||||
|
||||
def test_backoff_is_capped(self):
|
||||
"""Effective cadence is capped at cadence × _BACKOFF_MAX."""
|
||||
p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 2}})
|
||||
p._dialectic_empty_streak = 100
|
||||
# cadence=2, ceiling = 2 × 8 = 16
|
||||
assert p._effective_cadence() == 16
|
||||
|
||||
def test_success_resets_empty_streak(self):
|
||||
"""A non-empty result zeroes the streak so healthy operation restores
|
||||
the base cadence immediately."""
|
||||
p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 1}})
|
||||
p._session_key = "test"
|
||||
p._dialectic_empty_streak = 5
|
||||
p._turn_count = 10
|
||||
p._last_dialectic_turn = 0
|
||||
p._manager.dialectic_query.return_value = "real output"
|
||||
|
||||
p.queue_prefetch("hello")
|
||||
if p._prefetch_thread:
|
||||
p._prefetch_thread.join(timeout=2.0)
|
||||
assert p._dialectic_empty_streak == 0
|
||||
assert p._last_dialectic_turn == 10
|
||||
|
||||
def test_empty_result_increments_streak(self):
|
||||
p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 1}})
|
||||
p._session_key = "test"
|
||||
p._turn_count = 5
|
||||
p._last_dialectic_turn = 0
|
||||
p._manager.dialectic_query.return_value = "" # empty
|
||||
|
||||
p.queue_prefetch("hello")
|
||||
if p._prefetch_thread:
|
||||
p._prefetch_thread.join(timeout=2.0)
|
||||
assert p._dialectic_empty_streak == 1
|
||||
assert p._last_dialectic_turn == 0 # cadence not advanced
|
||||
|
||||
def test_liveness_snapshot_shape(self):
|
||||
p = self._make_provider()
|
||||
snap = p.liveness_snapshot()
|
||||
for key in (
|
||||
"turn_count", "last_dialectic_turn", "pending_result_fired_at",
|
||||
"empty_streak", "effective_cadence", "thread_alive", "thread_age_seconds",
|
||||
):
|
||||
assert key in snap
|
||||
|
||||
|
||||
class TestDialecticLifecycleSmoke:
|
||||
"""End-to-end smoke walking a multi-turn session through prewarm,
|
||||
turn 1 consume, trivial skip, cadence fire, empty-result retry,
|
||||
heuristic bump, and session-end flush."""
|
||||
|
||||
@staticmethod
|
||||
def _make_provider(cfg_extra=None):
|
||||
from unittest.mock import patch, MagicMock
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
defaults = dict(
|
||||
api_key="test-key", enabled=True, recall_mode="hybrid",
|
||||
dialectic_reasoning_level="low", reasoning_heuristic=True,
|
||||
reasoning_level_cap="high", dialectic_depth=1,
|
||||
)
|
||||
if cfg_extra:
|
||||
defaults.update(cfg_extra)
|
||||
cfg = HonchoClientConfig(**defaults)
|
||||
provider = HonchoMemoryProvider()
|
||||
mock_manager = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_session.messages = []
|
||||
mock_manager.get_or_create.return_value = mock_session
|
||||
mock_manager.get_prefetch_context.return_value = None
|
||||
mock_manager.pop_context_result.return_value = None
|
||||
|
||||
with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \
|
||||
patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \
|
||||
patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \
|
||||
patch("hermes_constants.get_hermes_home", return_value=MagicMock()):
|
||||
return provider, mock_manager, cfg
|
||||
|
||||
def _await_thread(self, provider):
|
||||
if provider._prefetch_thread:
|
||||
provider._prefetch_thread.join(timeout=3.0)
|
||||
|
||||
def test_full_multi_turn_session(self):
|
||||
"""Walks init → turns 1..8 → session end. Asserts at every step that
|
||||
the plugin did exactly what it should and nothing more.
|
||||
|
||||
Uses dialecticCadence=3 so we can exercise skip-turns between fires
|
||||
and the silent-failure retry path without their gates tripping each
|
||||
other. Trivial + slash skips apply independent of cadence.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
provider, mgr, cfg = self._make_provider(
|
||||
cfg_extra={"raw": {"dialecticCadence": 3}}
|
||||
)
|
||||
|
||||
# Program the dialectic responses in the exact order they'll be requested.
|
||||
# An extra or missing call fails the test — strong smoke signal.
|
||||
responses = iter([
|
||||
"prewarm: user is eri, works on hermes", # session-start prewarm
|
||||
"cadence fire: long query synthesis", # turn 4 queue_prefetch
|
||||
"", # turn 7 fire: silent failure
|
||||
"retry success: fresh synthesis", # turn 8 queue_prefetch retry
|
||||
])
|
||||
mgr.dialectic_query.side_effect = lambda *a, **kw: next(responses)
|
||||
|
||||
# ---- init: prewarm fires ----
|
||||
with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \
|
||||
patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \
|
||||
patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mgr), \
|
||||
patch("hermes_constants.get_hermes_home", return_value=MagicMock()):
|
||||
provider.initialize(session_id="smoke-test")
|
||||
|
||||
self._await_thread(provider)
|
||||
with provider._prefetch_lock:
|
||||
assert provider._prefetch_result.startswith("prewarm"), \
|
||||
"session-start prewarm must land in _prefetch_result"
|
||||
assert provider._last_dialectic_turn == 0, "prewarm marks turn 0"
|
||||
assert mgr.dialectic_query.call_count == 1
|
||||
|
||||
# ---- turn 1: consume prewarm, no duplicate dialectic ----
|
||||
provider.on_turn_start(1, "hey")
|
||||
inject1 = provider.prefetch("hey")
|
||||
assert "prewarm" in inject1, "turn 1 must surface prewarm"
|
||||
provider.sync_turn("hey", "hi there")
|
||||
provider.queue_prefetch("hey") # cadence gate: (1-0)<3 → skip
|
||||
self._await_thread(provider)
|
||||
assert mgr.dialectic_query.call_count == 1, \
|
||||
"turn 1 must not fire — prewarm covered it and cadence skips"
|
||||
|
||||
# ---- turn 2: trivial 'ok' → skip everything ----
|
||||
mgr.prefetch_context.reset_mock()
|
||||
provider.on_turn_start(2, "ok")
|
||||
assert provider.prefetch("ok") == "", "trivial prompt must short-circuit injection"
|
||||
provider.sync_turn("ok", "cool")
|
||||
provider.queue_prefetch("ok")
|
||||
self._await_thread(provider)
|
||||
assert mgr.dialectic_query.call_count == 1, "trivial must not fire dialectic"
|
||||
assert mgr.prefetch_context.call_count == 0, "trivial must not fire context refresh"
|
||||
|
||||
# ---- turn 3: slash '/help' → also skip ----
|
||||
provider.on_turn_start(3, "/help")
|
||||
assert provider.prefetch("/help") == ""
|
||||
provider.queue_prefetch("/help")
|
||||
assert mgr.dialectic_query.call_count == 1
|
||||
|
||||
# ---- turn 4: long query → cadence fires + heuristic bumps ----
|
||||
long_q = "walk me through " + ("x " * 100) # ~200 chars → heuristic +1
|
||||
provider.on_turn_start(4, long_q)
|
||||
provider.prefetch(long_q)
|
||||
provider.sync_turn(long_q, "sure")
|
||||
provider.queue_prefetch(long_q) # (4-0)≥3 → fires
|
||||
self._await_thread(provider)
|
||||
assert mgr.dialectic_query.call_count == 2, "turn 4 cadence fire"
|
||||
_, kwargs = mgr.dialectic_query.call_args
|
||||
assert kwargs.get("reasoning_level") in ("medium", "high"), \
|
||||
f"long query must bump reasoning level above 'low'; got {kwargs.get('reasoning_level')}"
|
||||
assert provider._last_dialectic_turn == 4, "cadence tracker advances on success"
|
||||
|
||||
# ---- turns 5–6: cadence cooldown, no fires ----
|
||||
for t in (5, 6):
|
||||
provider.on_turn_start(t, "tell me more")
|
||||
provider.queue_prefetch("tell me more")
|
||||
self._await_thread(provider)
|
||||
assert mgr.dialectic_query.call_count == 2, "turns 5–6 blocked by cadence window"
|
||||
|
||||
# ---- turn 7: fires but silent failure (empty dialectic) ----
|
||||
provider.on_turn_start(7, "and then what")
|
||||
provider.queue_prefetch("and then what") # (7-4)≥3 → fires
|
||||
self._await_thread(provider)
|
||||
assert mgr.dialectic_query.call_count == 3, "turn 7 fires"
|
||||
assert provider._last_dialectic_turn == 4, \
|
||||
"silent failure must NOT burn the cadence window"
|
||||
|
||||
# ---- turn 8: retries because cadence didn't advance ----
|
||||
provider.on_turn_start(8, "try again")
|
||||
provider.queue_prefetch("try again") # (8-4)≥3 → fires again
|
||||
self._await_thread(provider)
|
||||
assert mgr.dialectic_query.call_count == 4, \
|
||||
"turn 8 retries because turn 7's empty result didn't advance cadence"
|
||||
assert provider._last_dialectic_turn == 8, "retry success advances"
|
||||
|
||||
# ---- session end: flush messages ----
|
||||
provider.on_session_end([])
|
||||
mgr.flush_all.assert_called()
|
||||
|
||||
|
||||
class TestReasoningHeuristic:
|
||||
"""Char-count heuristic that scales the auto-injected reasoning level by
|
||||
query length, clamped at reasoning_level_cap."""
|
||||
|
||||
@staticmethod
|
||||
def _make_provider(cfg_extra=None):
|
||||
from unittest.mock import patch, MagicMock
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
defaults = dict(
|
||||
api_key="test-key", enabled=True, recall_mode="hybrid",
|
||||
dialectic_reasoning_level="low", reasoning_heuristic=True,
|
||||
reasoning_level_cap="high",
|
||||
)
|
||||
if cfg_extra:
|
||||
defaults.update(cfg_extra)
|
||||
cfg = HonchoClientConfig(**defaults)
|
||||
provider = HonchoMemoryProvider()
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_or_create.return_value = MagicMock(messages=[])
|
||||
with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \
|
||||
patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \
|
||||
patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \
|
||||
patch("hermes_constants.get_hermes_home", return_value=MagicMock()):
|
||||
provider.initialize(session_id="test-heuristic")
|
||||
_settle_prewarm(provider)
|
||||
return provider
|
||||
|
||||
def test_short_query_stays_at_base(self):
|
||||
p = self._make_provider()
|
||||
assert p._apply_reasoning_heuristic("low", "hey") == "low"
|
||||
|
||||
def test_medium_query_bumps_one_level(self):
|
||||
p = self._make_provider()
|
||||
q = "x" * 150
|
||||
assert p._apply_reasoning_heuristic("low", q) == "medium"
|
||||
|
||||
def test_long_query_bumps_two_levels(self):
|
||||
p = self._make_provider()
|
||||
q = "x" * 500
|
||||
assert p._apply_reasoning_heuristic("low", q) == "high"
|
||||
|
||||
def test_bump_respects_cap(self):
|
||||
p = self._make_provider(cfg_extra={"reasoning_level_cap": "medium"})
|
||||
q = "x" * 500 # would hit 'high' without the cap
|
||||
assert p._apply_reasoning_heuristic("low", q) == "medium"
|
||||
|
||||
def test_max_never_auto_selected_with_default_cap(self):
|
||||
p = self._make_provider(cfg_extra={"dialectic_reasoning_level": "high"})
|
||||
q = "x" * 500 # base=high, bump would push to 'max'
|
||||
assert p._apply_reasoning_heuristic("high", q) == "high"
|
||||
|
||||
def test_heuristic_disabled_returns_base(self):
|
||||
p = self._make_provider(cfg_extra={"reasoning_heuristic": False})
|
||||
q = "x" * 500
|
||||
assert p._apply_reasoning_heuristic("low", q) == "low"
|
||||
|
||||
def test_resolve_pass_level_applies_heuristic_at_base_mapping(self):
|
||||
"""Depth=1, pass 0 maps to 'base' → heuristic applies."""
|
||||
p = self._make_provider()
|
||||
q = "x" * 150
|
||||
assert p._resolve_pass_level(0, query=q) == "medium"
|
||||
|
||||
def test_resolve_pass_level_does_not_touch_explicit_per_pass(self):
|
||||
"""dialecticDepthLevels wins absolutely — no heuristic scaling."""
|
||||
p = self._make_provider(cfg_extra={"dialectic_depth_levels": ["minimal"]})
|
||||
q = "x" * 500 # heuristic would otherwise bump to 'high'
|
||||
assert p._resolve_pass_level(0, query=q) == "minimal"
|
||||
|
||||
def test_resolve_pass_level_does_not_touch_lighter_passes(self):
|
||||
"""Depth 3 pass 0 is hardcoded 'minimal' — heuristic must not bump it."""
|
||||
p = self._make_provider(cfg_extra={"dialectic_depth": 3})
|
||||
q = "x" * 500
|
||||
assert p._resolve_pass_level(0, query=q) == "minimal"
|
||||
# But the 'base' pass (idx 1 for depth 3) does get heuristic
|
||||
assert p._resolve_pass_level(1, query=q) == "high"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_peer_card None guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Regression tests for memory provider selection during AIAgent init."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_blank_memory_provider_does_not_auto_enable_honcho():
|
||||
"""Blank memory.provider should remain opt-out even if Honcho fallback looks configured."""
|
||||
cfg = {"memory": {"provider": ""}, "agent": {}}
|
||||
honcho_cfg = SimpleNamespace(enabled=True, api_key="stale-key", base_url=None)
|
||||
|
||||
with (
|
||||
patch("hermes_cli.config.load_config", return_value=cfg),
|
||||
patch("hermes_cli.config.save_config") as save_config,
|
||||
patch(
|
||||
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
||||
return_value=honcho_cfg,
|
||||
) as from_global_config,
|
||||
patch("plugins.memory.load_memory_provider") as load_memory_provider,
|
||||
patch("agent.model_metadata.get_model_context_length", return_value=204_800),
|
||||
patch("run_agent.get_tool_definitions", return_value=[]),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI"),
|
||||
):
|
||||
from run_agent import AIAgent
|
||||
|
||||
agent = AIAgent(
|
||||
api_key="test-key-1234567890",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=False,
|
||||
)
|
||||
|
||||
assert agent._memory_manager is None
|
||||
from_global_config.assert_not_called()
|
||||
load_memory_provider.assert_not_called()
|
||||
save_config.assert_not_called()
|
||||
|
||||
@@ -1285,6 +1285,7 @@ class TestExecuteToolCalls:
|
||||
tc = _mock_tool_call(name="web_search", arguments='{"q":"test"}', call_id="c1")
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc])
|
||||
messages = []
|
||||
agent.platform = "cli"
|
||||
agent.tool_progress_callback = None
|
||||
|
||||
with patch("run_agent.handle_function_call", return_value="search result"), \
|
||||
@@ -1296,6 +1297,21 @@ class TestExecuteToolCalls:
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "tool"
|
||||
|
||||
def test_quiet_tool_output_suppressed_without_progress_callback_for_non_cli_agent(self, agent):
|
||||
tc = _mock_tool_call(name="web_search", arguments='{"q":"test"}', call_id="c1")
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc])
|
||||
messages = []
|
||||
agent.platform = None
|
||||
agent.tool_progress_callback = None
|
||||
|
||||
with patch("run_agent.handle_function_call", return_value="search result"), \
|
||||
patch.object(agent, "_safe_print") as mock_print:
|
||||
agent._execute_tool_calls(mock_msg, messages, "task-1")
|
||||
|
||||
mock_print.assert_not_called()
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "tool"
|
||||
|
||||
def test_vprint_suppressed_in_parseable_quiet_mode(self, agent):
|
||||
agent.suppress_status_output = True
|
||||
|
||||
@@ -1876,6 +1892,30 @@ class TestRunConversation:
|
||||
assert all("message_count" in c and "messages" not in c for c in pre_request_calls)
|
||||
assert all("usage" in c and "response" not in c for c in post_request_calls)
|
||||
|
||||
def test_content_with_tool_calls_stays_silent_for_non_cli_quiet_mode(self, agent):
|
||||
self._setup_agent(agent)
|
||||
agent.platform = None
|
||||
tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1")
|
||||
resp1 = _mock_response(
|
||||
content="I'll search for that.",
|
||||
finish_reason="tool_calls",
|
||||
tool_calls=[tc],
|
||||
)
|
||||
resp2 = _mock_response(content="Done searching", finish_reason="stop")
|
||||
agent.client.chat.completions.create.side_effect = [resp1, resp2]
|
||||
|
||||
with (
|
||||
patch("run_agent.handle_function_call", return_value="search result"),
|
||||
patch.object(agent, "_safe_print") as mock_print,
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
result = agent.run_conversation("search something")
|
||||
|
||||
assert result["final_response"] == "Done searching"
|
||||
mock_print.assert_not_called()
|
||||
|
||||
def test_interrupt_breaks_loop(self, agent):
|
||||
self._setup_agent(agent)
|
||||
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
"""Regression tests for token accounting edge cases.
|
||||
|
||||
Fix 1 (#12023): When a provider returns no usage data in the streaming
|
||||
response (e.g. MiniMax via OpenRouter ignoring stream_options.include_usage),
|
||||
the agent falls back to rough token estimation so sessions don't permanently
|
||||
record 0/0 tokens.
|
||||
|
||||
Fix 2 (#12026): Reasoning tokens (from completion_tokens_details) are
|
||||
subtracted from the completion_tokens fed to the context compressor.
|
||||
Reasoning tokens are internal chain-of-thought that don't appear in the
|
||||
context window on the next turn; including them caused premature
|
||||
compression for thinking models (GLM-5.1, QwQ, DeepSeek-R1).
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.context_compressor import ContextCompressor
|
||||
from agent.usage_pricing import CanonicalUsage
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def compressor_200k():
|
||||
"""ContextCompressor with a 200K context window (GLM-5.1 sized)."""
|
||||
with patch(
|
||||
"agent.model_metadata.get_model_context_length", return_value=200_000
|
||||
):
|
||||
return ContextCompressor(
|
||||
model="z-ai/glm-5.1",
|
||||
threshold_percent=0.50,
|
||||
quiet_mode=True,
|
||||
)
|
||||
|
||||
|
||||
# ── Fix 2: reasoning tokens excluded from compressor ─────────────────
|
||||
|
||||
|
||||
class TestReasoningTokenExclusion:
|
||||
"""Verify that reasoning tokens are subtracted before feeding the
|
||||
context compressor, while session-level billing counters keep the
|
||||
full amount."""
|
||||
|
||||
def test_reasoning_subtracted_from_compressor(self, compressor_200k):
|
||||
"""Compressor should see content-only completion tokens."""
|
||||
compressor = compressor_200k
|
||||
|
||||
# Simulate: 80K prompt, 20K completion (15K reasoning + 5K content)
|
||||
canonical = CanonicalUsage(
|
||||
input_tokens=80_000,
|
||||
output_tokens=20_000,
|
||||
reasoning_tokens=15_000,
|
||||
)
|
||||
content_completion = canonical.output_tokens - canonical.reasoning_tokens
|
||||
compressor.update_from_response({
|
||||
"prompt_tokens": canonical.prompt_tokens,
|
||||
"completion_tokens": content_completion,
|
||||
"total_tokens": canonical.total_tokens,
|
||||
})
|
||||
|
||||
assert compressor.last_completion_tokens == 5_000
|
||||
assert compressor.last_prompt_tokens == canonical.prompt_tokens
|
||||
|
||||
def test_no_premature_compression_with_reasoning(self, compressor_200k):
|
||||
"""85K prompt + 20K reasoning should NOT trigger compression at
|
||||
50% of 200K (100K threshold). Without the fix, 85K + 20K = 105K
|
||||
would exceed the threshold."""
|
||||
compressor = compressor_200k
|
||||
# threshold = 100_000
|
||||
|
||||
canonical = CanonicalUsage(
|
||||
input_tokens=85_000,
|
||||
output_tokens=20_000,
|
||||
reasoning_tokens=15_000,
|
||||
)
|
||||
content_completion = canonical.output_tokens - canonical.reasoning_tokens
|
||||
compressor.update_from_response({
|
||||
"prompt_tokens": canonical.prompt_tokens,
|
||||
"completion_tokens": content_completion,
|
||||
"total_tokens": canonical.total_tokens,
|
||||
})
|
||||
|
||||
# prompt_tokens (85K) + content_completion (5K) = 90K < 100K threshold
|
||||
_real = compressor.last_prompt_tokens + compressor.last_completion_tokens
|
||||
assert _real == 90_000
|
||||
assert not compressor.should_compress(_real)
|
||||
|
||||
def test_compression_fires_when_truly_full(self, compressor_200k):
|
||||
"""When prompt alone exceeds the threshold, compression must still
|
||||
fire regardless of reasoning subtraction."""
|
||||
compressor = compressor_200k
|
||||
|
||||
canonical = CanonicalUsage(
|
||||
input_tokens=105_000,
|
||||
output_tokens=5_000,
|
||||
reasoning_tokens=3_000,
|
||||
)
|
||||
content_completion = canonical.output_tokens - canonical.reasoning_tokens
|
||||
compressor.update_from_response({
|
||||
"prompt_tokens": canonical.prompt_tokens,
|
||||
"completion_tokens": content_completion,
|
||||
"total_tokens": canonical.total_tokens,
|
||||
})
|
||||
|
||||
_real = compressor.last_prompt_tokens + compressor.last_completion_tokens
|
||||
assert _real == 107_000 # 105K + 2K
|
||||
assert compressor.should_compress(_real)
|
||||
|
||||
def test_zero_reasoning_tokens_no_change(self, compressor_200k):
|
||||
"""For non-thinking models (reasoning_tokens=0), the formula is
|
||||
identical to the old prompt+completion behavior."""
|
||||
compressor = compressor_200k
|
||||
|
||||
canonical = CanonicalUsage(
|
||||
input_tokens=80_000,
|
||||
output_tokens=10_000,
|
||||
reasoning_tokens=0,
|
||||
)
|
||||
content_completion = canonical.output_tokens - canonical.reasoning_tokens
|
||||
compressor.update_from_response({
|
||||
"prompt_tokens": canonical.prompt_tokens,
|
||||
"completion_tokens": content_completion,
|
||||
"total_tokens": canonical.total_tokens,
|
||||
})
|
||||
|
||||
assert compressor.last_completion_tokens == 10_000
|
||||
_real = compressor.last_prompt_tokens + compressor.last_completion_tokens
|
||||
assert _real == 90_000
|
||||
|
||||
|
||||
# ── Fix 1: token estimation fallback when usage is None ──────────────
|
||||
|
||||
|
||||
class TestTokenEstimationFallback:
|
||||
"""Verify that when response.usage is None, rough token estimation
|
||||
populates the compressor and session counters."""
|
||||
|
||||
def test_compressor_gets_nonzero_on_missing_usage(self, compressor_200k):
|
||||
"""Simulates the fallback path: estimate_messages_tokens_rough
|
||||
produces non-zero values that update the compressor."""
|
||||
compressor = compressor_200k
|
||||
|
||||
# Before: compressor has no data
|
||||
assert compressor.last_prompt_tokens == 0
|
||||
assert compressor.last_completion_tokens == 0
|
||||
|
||||
# Simulate fallback estimation
|
||||
est_in = 5000 # rough estimate from messages
|
||||
est_out = 200 # rough estimate from response content
|
||||
compressor.update_from_response({
|
||||
"prompt_tokens": est_in,
|
||||
"completion_tokens": est_out,
|
||||
"total_tokens": est_in + est_out,
|
||||
})
|
||||
|
||||
assert compressor.last_prompt_tokens == est_in
|
||||
assert compressor.last_completion_tokens == est_out
|
||||
|
||||
def test_fallback_prevents_zero_session_tokens(self):
|
||||
"""Session counters must be non-zero after the fallback path."""
|
||||
# This tests the *pattern*, not the full agent integration.
|
||||
session_prompt = 0
|
||||
session_completion = 0
|
||||
|
||||
est_in = 3000
|
||||
est_out = 150
|
||||
|
||||
session_prompt += est_in
|
||||
session_completion += est_out
|
||||
|
||||
assert session_prompt > 0
|
||||
assert session_completion > 0
|
||||
@@ -712,3 +712,119 @@ def test_prompt_submit_history_version_match_persists_normally(monkeypatch):
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# session.interrupt must only cancel pending prompts owned by the calling
|
||||
# session — it must not blast-resolve clarify/sudo/secret prompts on
|
||||
# unrelated sessions sharing the same tui_gateway process. Without
|
||||
# session scoping the other sessions' prompts silently resolve to empty
|
||||
# strings, unblocking their agent threads as if the user cancelled.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_interrupt_only_clears_own_session_pending():
|
||||
"""session.interrupt on session A must NOT release pending prompts
|
||||
that belong to session B."""
|
||||
import types
|
||||
|
||||
session_a = _session()
|
||||
session_a["agent"] = types.SimpleNamespace(interrupt=lambda: None)
|
||||
session_b = _session()
|
||||
session_b["agent"] = types.SimpleNamespace(interrupt=lambda: None)
|
||||
server._sessions["sid_a"] = session_a
|
||||
server._sessions["sid_b"] = session_b
|
||||
|
||||
try:
|
||||
# Simulate pending prompts on both sessions (what _block creates
|
||||
# while a clarify/sudo/secret request is outstanding).
|
||||
ev_a = threading.Event()
|
||||
ev_b = threading.Event()
|
||||
server._pending["rid-a"] = ("sid_a", ev_a)
|
||||
server._pending["rid-b"] = ("sid_b", ev_b)
|
||||
server._answers.clear()
|
||||
|
||||
# Interrupt session A.
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.interrupt", "params": {"session_id": "sid_a"}}
|
||||
)
|
||||
assert resp.get("result"), f"got error: {resp.get('error')}"
|
||||
|
||||
# Session A's pending must be released to empty.
|
||||
assert ev_a.is_set(), "sid_a pending Event should be set after interrupt"
|
||||
assert server._answers.get("rid-a") == ""
|
||||
|
||||
# Session B's pending MUST remain untouched — no cross-session blast.
|
||||
assert not ev_b.is_set(), (
|
||||
"CRITICAL: session.interrupt on sid_a released a pending prompt "
|
||||
"belonging to sid_b — other sessions' clarify/sudo/secret "
|
||||
"prompts are being silently cancelled"
|
||||
)
|
||||
assert "rid-b" not in server._answers
|
||||
finally:
|
||||
server._sessions.pop("sid_a", None)
|
||||
server._sessions.pop("sid_b", None)
|
||||
server._pending.pop("rid-a", None)
|
||||
server._pending.pop("rid-b", None)
|
||||
server._answers.pop("rid-a", None)
|
||||
server._answers.pop("rid-b", None)
|
||||
|
||||
|
||||
def test_interrupt_clears_multiple_own_pending():
|
||||
"""When a single session has multiple pending prompts (uncommon but
|
||||
possible via nested tool calls), interrupt must release all of them."""
|
||||
import types
|
||||
|
||||
sess = _session()
|
||||
sess["agent"] = types.SimpleNamespace(interrupt=lambda: None)
|
||||
server._sessions["sid"] = sess
|
||||
|
||||
try:
|
||||
ev1, ev2 = threading.Event(), threading.Event()
|
||||
server._pending["r1"] = ("sid", ev1)
|
||||
server._pending["r2"] = ("sid", ev2)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.interrupt", "params": {"session_id": "sid"}}
|
||||
)
|
||||
assert resp.get("result")
|
||||
assert ev1.is_set() and ev2.is_set()
|
||||
assert server._answers.get("r1") == "" and server._answers.get("r2") == ""
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
for key in ("r1", "r2"):
|
||||
server._pending.pop(key, None)
|
||||
server._answers.pop(key, None)
|
||||
|
||||
|
||||
def test_clear_pending_without_sid_clears_all():
|
||||
"""_clear_pending(None) is the shutdown path — must still release
|
||||
every pending prompt regardless of owning session."""
|
||||
ev1, ev2, ev3 = threading.Event(), threading.Event(), threading.Event()
|
||||
server._pending["a"] = ("sid_x", ev1)
|
||||
server._pending["b"] = ("sid_y", ev2)
|
||||
server._pending["c"] = ("sid_z", ev3)
|
||||
try:
|
||||
server._clear_pending(None)
|
||||
assert ev1.is_set() and ev2.is_set() and ev3.is_set()
|
||||
finally:
|
||||
for key in ("a", "b", "c"):
|
||||
server._pending.pop(key, None)
|
||||
server._answers.pop(key, None)
|
||||
|
||||
|
||||
def test_respond_unpacks_sid_tuple_correctly():
|
||||
"""After the (sid, Event) tuple change, _respond must still work."""
|
||||
ev = threading.Event()
|
||||
server._pending["rid-x"] = ("sid_x", ev)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "clarify.respond",
|
||||
"params": {"request_id": "rid-x", "answer": "the answer"}}
|
||||
)
|
||||
assert resp.get("result")
|
||||
assert ev.is_set()
|
||||
assert server._answers.get("rid-x") == "the answer"
|
||||
finally:
|
||||
server._pending.pop("rid-x", None)
|
||||
server._answers.pop("rid-x", None)
|
||||
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
"""Unit tests for browser_cdp tool.
|
||||
|
||||
Uses a tiny in-process ``websockets`` server to simulate a CDP endpoint —
|
||||
gives real protocol coverage (connect, send, recv, close) without needing
|
||||
a real Chrome instance.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
import websockets
|
||||
from websockets.asyncio.server import serve
|
||||
|
||||
from tools import browser_cdp_tool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process CDP mock server
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _CDPServer:
|
||||
"""A tiny CDP-over-WebSocket mock.
|
||||
|
||||
Each client gets a greeting-free stream. The server replies to each
|
||||
inbound request whose ``id`` is set, using the registered handler for
|
||||
that method. If no handler is registered, returns a generic CDP error.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._handlers: Dict[str, Any] = {}
|
||||
self._responses: List[Dict[str, Any]] = []
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._server: Any = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._host = "127.0.0.1"
|
||||
self._port = 0
|
||||
|
||||
# --- handler registration --------------------------------------------
|
||||
|
||||
def on(self, method: str, handler):
|
||||
"""Register a handler ``handler(params, session_id) -> dict or Exception``."""
|
||||
self._handlers[method] = handler
|
||||
|
||||
# --- lifecycle -------------------------------------------------------
|
||||
|
||||
def start(self) -> str:
|
||||
ready = threading.Event()
|
||||
|
||||
def _run() -> None:
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
|
||||
async def _handler(ws):
|
||||
try:
|
||||
async for raw in ws:
|
||||
msg = json.loads(raw)
|
||||
call_id = msg.get("id")
|
||||
method = msg.get("method", "")
|
||||
params = msg.get("params", {}) or {}
|
||||
session_id = msg.get("sessionId")
|
||||
self._responses.append(msg)
|
||||
|
||||
fn = self._handlers.get(method)
|
||||
if fn is None:
|
||||
reply = {
|
||||
"id": call_id,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": f"No handler for {method}",
|
||||
},
|
||||
}
|
||||
else:
|
||||
try:
|
||||
result = fn(params, session_id)
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
reply = {"id": call_id, "result": result}
|
||||
except Exception as exc:
|
||||
reply = {
|
||||
"id": call_id,
|
||||
"error": {"code": -1, "message": str(exc)},
|
||||
}
|
||||
if session_id:
|
||||
reply["sessionId"] = session_id
|
||||
await ws.send(json.dumps(reply))
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
pass
|
||||
|
||||
async def _serve() -> None:
|
||||
self._server = await serve(_handler, self._host, 0)
|
||||
sock = next(iter(self._server.sockets))
|
||||
self._port = sock.getsockname()[1]
|
||||
ready.set()
|
||||
await self._server.wait_closed()
|
||||
|
||||
try:
|
||||
self._loop.run_until_complete(_serve())
|
||||
finally:
|
||||
self._loop.close()
|
||||
|
||||
self._thread = threading.Thread(target=_run, daemon=True)
|
||||
self._thread.start()
|
||||
if not ready.wait(timeout=5.0):
|
||||
raise RuntimeError("CDP mock server failed to start within 5s")
|
||||
return f"ws://{self._host}:{self._port}/devtools/browser/mock"
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._loop and self._server:
|
||||
def _close() -> None:
|
||||
self._server.close()
|
||||
|
||||
self._loop.call_soon_threadsafe(_close)
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3.0)
|
||||
|
||||
def received(self) -> List[Dict[str, Any]]:
|
||||
return list(self._responses)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cdp_server(monkeypatch):
|
||||
"""Start a CDP mock and route tool resolution to it."""
|
||||
server = _CDPServer()
|
||||
ws_url = server.start()
|
||||
monkeypatch.setattr(
|
||||
browser_cdp_tool, "_resolve_cdp_endpoint", lambda: ws_url
|
||||
)
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_method_returns_error():
|
||||
result = json.loads(browser_cdp_tool.browser_cdp(method=""))
|
||||
assert "error" in result
|
||||
assert "method" in result["error"].lower()
|
||||
assert result.get("cdp_docs") == browser_cdp_tool.CDP_DOCS_URL
|
||||
|
||||
|
||||
def test_non_string_method_returns_error():
|
||||
result = json.loads(browser_cdp_tool.browser_cdp(method=123)) # type: ignore[arg-type]
|
||||
assert "error" in result
|
||||
assert "method" in result["error"].lower()
|
||||
|
||||
|
||||
def test_non_dict_params_returns_error(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
browser_cdp_tool, "_resolve_cdp_endpoint", lambda: "ws://localhost:9999"
|
||||
)
|
||||
result = json.loads(
|
||||
browser_cdp_tool.browser_cdp(method="Target.getTargets", params="not-a-dict") # type: ignore[arg-type]
|
||||
)
|
||||
assert "error" in result
|
||||
assert "object" in result["error"].lower() or "dict" in result["error"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_endpoint_returns_helpful_error(monkeypatch):
|
||||
monkeypatch.setattr(browser_cdp_tool, "_resolve_cdp_endpoint", lambda: "")
|
||||
result = json.loads(browser_cdp_tool.browser_cdp(method="Target.getTargets"))
|
||||
assert "error" in result
|
||||
assert "/browser connect" in result["error"]
|
||||
assert result.get("cdp_docs") == browser_cdp_tool.CDP_DOCS_URL
|
||||
|
||||
|
||||
def test_non_ws_endpoint_returns_error(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
browser_cdp_tool, "_resolve_cdp_endpoint", lambda: "http://localhost:9222"
|
||||
)
|
||||
result = json.loads(browser_cdp_tool.browser_cdp(method="Target.getTargets"))
|
||||
assert "error" in result
|
||||
assert "WebSocket" in result["error"]
|
||||
|
||||
|
||||
def test_websockets_missing_returns_error(monkeypatch):
|
||||
monkeypatch.setattr(browser_cdp_tool, "_WS_AVAILABLE", False)
|
||||
result = json.loads(browser_cdp_tool.browser_cdp(method="Target.getTargets"))
|
||||
assert "error" in result
|
||||
assert "websockets" in result["error"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy-path: browser-level call
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_browser_level_success(cdp_server):
|
||||
cdp_server.on(
|
||||
"Target.getTargets",
|
||||
lambda params, sid: {
|
||||
"targetInfos": [
|
||||
{"targetId": "A", "type": "page", "title": "Tab 1", "url": "about:blank"},
|
||||
{"targetId": "B", "type": "page", "title": "Tab 2", "url": "https://a.test"},
|
||||
]
|
||||
},
|
||||
)
|
||||
result = json.loads(browser_cdp_tool.browser_cdp(method="Target.getTargets"))
|
||||
assert result["success"] is True
|
||||
assert result["method"] == "Target.getTargets"
|
||||
assert "target_id" not in result
|
||||
assert len(result["result"]["targetInfos"]) == 2
|
||||
# Verify the server actually received exactly one call (no extra traffic)
|
||||
calls = cdp_server.received()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["method"] == "Target.getTargets"
|
||||
assert "sessionId" not in calls[0]
|
||||
|
||||
|
||||
def test_empty_params_sends_empty_object(cdp_server):
|
||||
cdp_server.on("Browser.getVersion", lambda params, sid: {"product": "Mock/1.0"})
|
||||
json.loads(browser_cdp_tool.browser_cdp(method="Browser.getVersion"))
|
||||
assert cdp_server.received()[0]["params"] == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy-path: target-attached call
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_target_attach_then_call(cdp_server):
|
||||
cdp_server.on(
|
||||
"Target.attachToTarget",
|
||||
lambda params, sid: {"sessionId": f"sess-{params['targetId']}"},
|
||||
)
|
||||
cdp_server.on(
|
||||
"Runtime.evaluate",
|
||||
lambda params, sid: {
|
||||
"result": {"type": "string", "value": f"evaluated[{sid}]"},
|
||||
},
|
||||
)
|
||||
result = json.loads(
|
||||
browser_cdp_tool.browser_cdp(
|
||||
method="Runtime.evaluate",
|
||||
params={"expression": "document.title", "returnByValue": True},
|
||||
target_id="tab-A",
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["target_id"] == "tab-A"
|
||||
assert result["result"]["result"]["value"] == "evaluated[sess-tab-A]"
|
||||
|
||||
calls = cdp_server.received()
|
||||
# First call: attach
|
||||
assert calls[0]["method"] == "Target.attachToTarget"
|
||||
assert calls[0]["params"] == {"targetId": "tab-A", "flatten": True}
|
||||
# Second call: dispatched method on the session
|
||||
assert calls[1]["method"] == "Runtime.evaluate"
|
||||
assert calls[1]["sessionId"] == "sess-tab-A"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CDP error responses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cdp_method_error_returns_tool_error(cdp_server):
|
||||
# No handler registered -> server returns CDP error
|
||||
result = json.loads(
|
||||
browser_cdp_tool.browser_cdp(method="NonExistent.method")
|
||||
)
|
||||
assert "error" in result
|
||||
assert "CDP error" in result["error"]
|
||||
assert result.get("method") == "NonExistent.method"
|
||||
|
||||
|
||||
def test_attach_failure_returns_tool_error(cdp_server):
|
||||
# Target.attachToTarget has no handler -> server errors on attach
|
||||
result = json.loads(
|
||||
browser_cdp_tool.browser_cdp(
|
||||
method="Runtime.evaluate",
|
||||
params={"expression": "1+1"},
|
||||
target_id="missing",
|
||||
)
|
||||
)
|
||||
assert "error" in result
|
||||
assert "Target.attachToTarget" in result["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timeouts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_timeout_when_server_never_replies(cdp_server):
|
||||
# Register a handler that blocks forever
|
||||
def slow(params, sid):
|
||||
time.sleep(10)
|
||||
return {}
|
||||
|
||||
cdp_server.on("Page.slowMethod", slow)
|
||||
result = json.loads(
|
||||
browser_cdp_tool.browser_cdp(
|
||||
method="Page.slowMethod", timeout=0.5
|
||||
)
|
||||
)
|
||||
assert "error" in result
|
||||
assert "tim" in result["error"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timeout clamping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_timeout_clamped_above_max(cdp_server):
|
||||
cdp_server.on("Browser.getVersion", lambda p, s: {"product": "ok"})
|
||||
# timeout=10_000 should be clamped to 300 but still succeed
|
||||
result = json.loads(
|
||||
browser_cdp_tool.browser_cdp(method="Browser.getVersion", timeout=10_000)
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
def test_invalid_timeout_falls_back_to_default(cdp_server):
|
||||
cdp_server.on("Browser.getVersion", lambda p, s: {"product": "ok"})
|
||||
result = json.loads(
|
||||
browser_cdp_tool.browser_cdp(method="Browser.getVersion", timeout="nope") # type: ignore[arg-type]
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registered_in_browser_toolset():
|
||||
from tools.registry import registry
|
||||
|
||||
entry = registry.get_entry("browser_cdp")
|
||||
assert entry is not None
|
||||
assert entry.toolset == "browser"
|
||||
assert entry.schema["name"] == "browser_cdp"
|
||||
assert entry.schema["parameters"]["required"] == ["method"]
|
||||
assert "Chrome DevTools Protocol" in entry.schema["description"]
|
||||
assert browser_cdp_tool.CDP_DOCS_URL in entry.schema["description"]
|
||||
|
||||
|
||||
def test_dispatch_through_registry(cdp_server):
|
||||
from tools.registry import registry
|
||||
|
||||
cdp_server.on("Target.getTargets", lambda p, s: {"targetInfos": []})
|
||||
raw = registry.dispatch(
|
||||
"browser_cdp", {"method": "Target.getTargets"}, task_id="t1"
|
||||
)
|
||||
result = json.loads(raw)
|
||||
assert result["success"] is True
|
||||
assert result["method"] == "Target.getTargets"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_fn gating
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_fn_false_when_no_cdp_url(monkeypatch):
|
||||
"""Gate closes when no CDP URL is set — even if the browser toolset is
|
||||
otherwise configured."""
|
||||
import tools.browser_tool as bt
|
||||
|
||||
monkeypatch.setattr(bt, "check_browser_requirements", lambda: True)
|
||||
monkeypatch.setattr(bt, "_get_cdp_override", lambda: "")
|
||||
assert browser_cdp_tool._browser_cdp_check() is False
|
||||
|
||||
|
||||
def test_check_fn_true_when_cdp_url_set(monkeypatch):
|
||||
"""Gate opens as soon as a CDP URL is resolvable."""
|
||||
import tools.browser_tool as bt
|
||||
|
||||
monkeypatch.setattr(bt, "check_browser_requirements", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
bt, "_get_cdp_override", lambda: "ws://localhost:9222/devtools/browser/x"
|
||||
)
|
||||
assert browser_cdp_tool._browser_cdp_check() is True
|
||||
|
||||
|
||||
def test_check_fn_false_when_browser_requirements_fail(monkeypatch):
|
||||
"""Even with a CDP URL, gate closes if the overall browser toolset is
|
||||
unavailable (e.g. agent-browser not installed)."""
|
||||
import tools.browser_tool as bt
|
||||
|
||||
monkeypatch.setattr(bt, "check_browser_requirements", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
bt, "_get_cdp_override", lambda: "ws://localhost:9222/devtools/browser/x"
|
||||
)
|
||||
assert browser_cdp_tool._browser_cdp_check() is False
|
||||
@@ -120,7 +120,9 @@ def test_block_and_respond(capture):
|
||||
|
||||
rid = next(iter(server._pending))
|
||||
server._answers[rid] = "my_answer"
|
||||
server._pending[rid].set()
|
||||
# _pending values are (sid, Event) tuples — unpack to set the Event
|
||||
_, ev = server._pending[rid]
|
||||
ev.set()
|
||||
|
||||
threading.Event().wait(0.1)
|
||||
assert result[0] == "my_answer"
|
||||
@@ -128,7 +130,8 @@ def test_block_and_respond(capture):
|
||||
|
||||
def test_clear_pending(server):
|
||||
ev = threading.Event()
|
||||
server._pending["r1"] = ev
|
||||
# _pending values are (sid, Event) tuples
|
||||
server._pending["r1"] = ("sid-x", ev)
|
||||
server._clear_pending()
|
||||
|
||||
assert ev.is_set()
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Raw Chrome DevTools Protocol (CDP) passthrough tool.
|
||||
|
||||
Exposes a single tool, ``browser_cdp``, that sends arbitrary CDP commands to
|
||||
the browser's DevTools WebSocket endpoint. Works when a CDP URL is
|
||||
configured — either via ``/browser connect`` (sets ``BROWSER_CDP_URL``) or
|
||||
``browser.cdp_url`` in ``config.yaml`` — or when a CDP-backed cloud provider
|
||||
session is active.
|
||||
|
||||
This is the escape hatch for browser operations not covered by the main
|
||||
browser tool surface (``browser_navigate``, ``browser_click``,
|
||||
``browser_console``, etc.) — handling native dialogs, iframe-scoped
|
||||
evaluation, cookie/network control, low-level tab management, etc.
|
||||
|
||||
Method reference: https://chromedevtools.github.io/devtools-protocol/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from tools.registry import registry, tool_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CDP_DOCS_URL = "https://chromedevtools.github.io/devtools-protocol/"
|
||||
|
||||
# ``websockets`` is a transitive dependency of hermes-agent (via fal_client
|
||||
# and firecrawl-py) and is already imported by gateway/platforms/feishu.py.
|
||||
# Wrap the import so a clean error surfaces if the package is ever absent.
|
||||
try:
|
||||
import websockets
|
||||
from websockets.exceptions import WebSocketException
|
||||
|
||||
_WS_AVAILABLE = True
|
||||
except ImportError:
|
||||
websockets = None # type: ignore[assignment]
|
||||
WebSocketException = Exception # type: ignore[assignment,misc]
|
||||
_WS_AVAILABLE = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async-from-sync bridge (matches the pattern in homeassistant_tool.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
"""Run an async coroutine from a sync handler, safe inside or outside a loop."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, coro)
|
||||
return future.result()
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_cdp_endpoint() -> str:
|
||||
"""Return the normalized CDP WebSocket URL, or empty string if unavailable.
|
||||
|
||||
Delegates to ``tools.browser_tool._get_cdp_override`` so precedence stays
|
||||
consistent with the rest of the browser tool surface:
|
||||
|
||||
1. ``BROWSER_CDP_URL`` env var (live override from ``/browser connect``)
|
||||
2. ``browser.cdp_url`` in ``config.yaml``
|
||||
"""
|
||||
try:
|
||||
from tools.browser_tool import _get_cdp_override # type: ignore[import-not-found]
|
||||
|
||||
return (_get_cdp_override() or "").strip()
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.debug("browser_cdp: failed to resolve CDP endpoint: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core CDP call
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _cdp_call(
|
||||
ws_url: str,
|
||||
method: str,
|
||||
params: Dict[str, Any],
|
||||
target_id: Optional[str],
|
||||
timeout: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make a single CDP call, optionally attaching to a target first.
|
||||
|
||||
When ``target_id`` is provided, we call ``Target.attachToTarget`` with
|
||||
``flatten=True`` to multiplex a page-level session over the same
|
||||
browser-level WebSocket, then send ``method`` with that ``sessionId``.
|
||||
When ``target_id`` is None, ``method`` is sent at browser level — which
|
||||
works for ``Target.*``, ``Browser.*``, ``Storage.*`` and a few other
|
||||
globally-scoped domains.
|
||||
"""
|
||||
assert websockets is not None # guarded by _WS_AVAILABLE at call-site
|
||||
|
||||
async with websockets.connect(
|
||||
ws_url,
|
||||
max_size=None, # CDP responses (e.g. DOM.getDocument) can be large
|
||||
open_timeout=timeout,
|
||||
close_timeout=5,
|
||||
ping_interval=None, # CDP server doesn't expect pings
|
||||
) as ws:
|
||||
next_id = 1
|
||||
session_id: Optional[str] = None
|
||||
|
||||
# --- Step 1: attach to target if requested ---
|
||||
if target_id:
|
||||
attach_id = next_id
|
||||
next_id += 1
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"id": attach_id,
|
||||
"method": "Target.attachToTarget",
|
||||
"params": {"targetId": target_id, "flatten": True},
|
||||
}
|
||||
)
|
||||
)
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
while True:
|
||||
remaining = deadline - asyncio.get_event_loop().time()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(
|
||||
f"Timed out attaching to target {target_id}"
|
||||
)
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
|
||||
msg = json.loads(raw)
|
||||
if msg.get("id") == attach_id:
|
||||
if "error" in msg:
|
||||
raise RuntimeError(
|
||||
f"Target.attachToTarget failed: {msg['error']}"
|
||||
)
|
||||
session_id = msg.get("result", {}).get("sessionId")
|
||||
if not session_id:
|
||||
raise RuntimeError(
|
||||
"Target.attachToTarget did not return a sessionId"
|
||||
)
|
||||
break
|
||||
# Ignore events (messages without "id") while waiting
|
||||
|
||||
# --- Step 2: dispatch the real method ---
|
||||
call_id = next_id
|
||||
next_id += 1
|
||||
req: Dict[str, Any] = {
|
||||
"id": call_id,
|
||||
"method": method,
|
||||
"params": params or {},
|
||||
}
|
||||
if session_id:
|
||||
req["sessionId"] = session_id
|
||||
await ws.send(json.dumps(req))
|
||||
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
while True:
|
||||
remaining = deadline - asyncio.get_event_loop().time()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for response to {method}"
|
||||
)
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
|
||||
msg = json.loads(raw)
|
||||
if msg.get("id") == call_id:
|
||||
if "error" in msg:
|
||||
raise RuntimeError(f"CDP error: {msg['error']}")
|
||||
return msg.get("result", {})
|
||||
# Ignore events / out-of-order responses
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public tool function
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def browser_cdp(
|
||||
method: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
target_id: Optional[str] = None,
|
||||
timeout: float = 30.0,
|
||||
task_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Send a raw CDP command. See ``CDP_DOCS_URL`` for method documentation.
|
||||
|
||||
Args:
|
||||
method: CDP method name, e.g. ``"Target.getTargets"``.
|
||||
params: Method-specific parameters; defaults to ``{}``.
|
||||
target_id: Optional target/tab ID for page-level methods. When set,
|
||||
we first attach to the target (``flatten=True``) and send
|
||||
``method`` with the resulting ``sessionId``.
|
||||
timeout: Seconds to wait for the call to complete.
|
||||
task_id: Unused (tool is stateless) — accepted for uniformity with
|
||||
other browser tools.
|
||||
|
||||
Returns:
|
||||
JSON string ``{"success": True, "method": ..., "result": {...}}`` on
|
||||
success, or ``{"error": "..."}`` on failure.
|
||||
"""
|
||||
del task_id # unused — stateless
|
||||
|
||||
if not method or not isinstance(method, str):
|
||||
return tool_error(
|
||||
"'method' is required (e.g. 'Target.getTargets')",
|
||||
cdp_docs=CDP_DOCS_URL,
|
||||
)
|
||||
|
||||
if not _WS_AVAILABLE:
|
||||
return tool_error(
|
||||
"The 'websockets' Python package is required but not installed. "
|
||||
"Install it with: pip install websockets"
|
||||
)
|
||||
|
||||
endpoint = _resolve_cdp_endpoint()
|
||||
if not endpoint:
|
||||
return tool_error(
|
||||
"No CDP endpoint is available. Run '/browser connect' to attach "
|
||||
"to a running Chrome, or set 'browser.cdp_url' in config.yaml. "
|
||||
"The Camofox backend is REST-only and does not expose CDP.",
|
||||
cdp_docs=CDP_DOCS_URL,
|
||||
)
|
||||
|
||||
if not endpoint.startswith(("ws://", "wss://")):
|
||||
return tool_error(
|
||||
f"CDP endpoint is not a WebSocket URL: {endpoint!r}. "
|
||||
"Expected ws://... or wss://... — the /browser connect "
|
||||
"resolver should have rewritten this. Check that Chrome is "
|
||||
"actually listening on the debug port."
|
||||
)
|
||||
|
||||
call_params: Dict[str, Any] = params or {}
|
||||
if not isinstance(call_params, dict):
|
||||
return tool_error(
|
||||
f"'params' must be an object/dict, got {type(call_params).__name__}"
|
||||
)
|
||||
|
||||
try:
|
||||
safe_timeout = float(timeout) if timeout else 30.0
|
||||
except (TypeError, ValueError):
|
||||
safe_timeout = 30.0
|
||||
safe_timeout = max(1.0, min(safe_timeout, 300.0))
|
||||
|
||||
try:
|
||||
result = _run_async(
|
||||
_cdp_call(endpoint, method, call_params, target_id, safe_timeout)
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
return tool_error(
|
||||
f"CDP call timed out after {safe_timeout}s: {exc}",
|
||||
method=method,
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
return tool_error(str(exc), method=method)
|
||||
except RuntimeError as exc:
|
||||
return tool_error(str(exc), method=method)
|
||||
except WebSocketException as exc:
|
||||
return tool_error(
|
||||
f"WebSocket error talking to CDP at {endpoint}: {exc}. The "
|
||||
"browser may have disconnected — try '/browser connect' again.",
|
||||
method=method,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover — unexpected
|
||||
logger.exception("browser_cdp unexpected error")
|
||||
return tool_error(
|
||||
f"Unexpected error: {type(exc).__name__}: {exc}",
|
||||
method=method,
|
||||
)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"success": True,
|
||||
"method": method,
|
||||
"result": result,
|
||||
}
|
||||
if target_id:
|
||||
payload["target_id"] = target_id
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
BROWSER_CDP_SCHEMA: Dict[str, Any] = {
|
||||
"name": "browser_cdp",
|
||||
"description": (
|
||||
"Send a raw Chrome DevTools Protocol (CDP) command. Escape hatch for "
|
||||
"browser operations not covered by browser_navigate, browser_click, "
|
||||
"browser_console, etc.\n\n"
|
||||
"**Requires a reachable CDP endpoint.** Available when the user has "
|
||||
"run '/browser connect' to attach to a running Chrome, or when "
|
||||
"'browser.cdp_url' is set in config.yaml. Not currently wired up for "
|
||||
"cloud backends (Browserbase, Browser Use, Firecrawl) — those expose "
|
||||
"CDP per session but live-session routing is a follow-up. Camofox is "
|
||||
"REST-only and will never support CDP. If the tool is in your toolset "
|
||||
"at all, a CDP endpoint is already reachable.\n\n"
|
||||
f"**CDP method reference:** {CDP_DOCS_URL} — use web_extract on a "
|
||||
"method's URL (e.g. '/tot/Page/#method-handleJavaScriptDialog') "
|
||||
"to look up parameters and return shape.\n\n"
|
||||
"**Common patterns:**\n"
|
||||
"- List tabs: method='Target.getTargets', params={}\n"
|
||||
"- Handle a native JS dialog: method='Page.handleJavaScriptDialog', "
|
||||
"params={'accept': true, 'promptText': ''}, target_id=<tabId>\n"
|
||||
"- Get all cookies: method='Network.getAllCookies', params={}\n"
|
||||
"- Eval in a specific tab: method='Runtime.evaluate', "
|
||||
"params={'expression': '...', 'returnByValue': true}, "
|
||||
"target_id=<tabId>\n"
|
||||
"- Set viewport for a tab: method='Emulation.setDeviceMetricsOverride', "
|
||||
"params={'width': 1280, 'height': 720, 'deviceScaleFactor': 1, "
|
||||
"'mobile': false}, target_id=<tabId>\n\n"
|
||||
"**Usage rules:**\n"
|
||||
"- Browser-level methods (Target.*, Browser.*, Storage.*): omit "
|
||||
"target_id.\n"
|
||||
"- Page-level methods (Page.*, Runtime.*, DOM.*, Emulation.*, "
|
||||
"Network.* scoped to a tab): pass target_id from Target.getTargets.\n"
|
||||
"- Each call is independent — sessions and event subscriptions do "
|
||||
"not persist between calls. For stateful workflows, prefer the "
|
||||
"dedicated browser tools."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"CDP method name, e.g. 'Target.getTargets', "
|
||||
"'Runtime.evaluate', 'Page.handleJavaScriptDialog'."
|
||||
),
|
||||
},
|
||||
"params": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Method-specific parameters as a JSON object. Omit or "
|
||||
"pass {} for methods that take no parameters."
|
||||
),
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"target_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional. Target/tab ID from Target.getTargets result "
|
||||
"(each entry's 'targetId'). Required for page-level "
|
||||
"methods; must be omitted for browser-level methods."
|
||||
),
|
||||
},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"description": (
|
||||
"Timeout in seconds (default 30, max 300)."
|
||||
),
|
||||
"default": 30,
|
||||
},
|
||||
},
|
||||
"required": ["method"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _browser_cdp_check() -> bool:
|
||||
"""Availability check for browser_cdp.
|
||||
|
||||
The tool is only offered when the Python side can actually reach a CDP
|
||||
endpoint right now — meaning a static URL is set via ``/browser connect``
|
||||
(``BROWSER_CDP_URL``) or ``browser.cdp_url`` in ``config.yaml``.
|
||||
|
||||
Backends that do *not* currently expose CDP to us — Camofox (REST-only),
|
||||
the default local agent-browser mode (Playwright hides its internal CDP
|
||||
port), and cloud providers whose per-session ``cdp_url`` is not yet
|
||||
surfaced — are gated out so the model doesn't see a tool that would
|
||||
reliably fail. Cloud-provider CDP routing is a follow-up.
|
||||
|
||||
Kept in a thin wrapper so the registration statement stays at module top
|
||||
level (the tool-discovery AST scan only picks up top-level
|
||||
``registry.register(...)`` calls).
|
||||
"""
|
||||
try:
|
||||
from tools.browser_tool import ( # type: ignore[import-not-found]
|
||||
_get_cdp_override,
|
||||
check_browser_requirements,
|
||||
)
|
||||
except ImportError as exc: # pragma: no cover — defensive
|
||||
logger.debug("browser_cdp check: browser_tool import failed: %s", exc)
|
||||
return False
|
||||
if not check_browser_requirements():
|
||||
return False
|
||||
return bool(_get_cdp_override())
|
||||
|
||||
|
||||
registry.register(
|
||||
name="browser_cdp",
|
||||
toolset="browser",
|
||||
schema=BROWSER_CDP_SCHEMA,
|
||||
handler=lambda args, **kw: browser_cdp(
|
||||
method=args.get("method", ""),
|
||||
params=args.get("params"),
|
||||
target_id=args.get("target_id"),
|
||||
timeout=args.get("timeout", 30.0),
|
||||
task_id=kw.get("task_id"),
|
||||
),
|
||||
check_fn=_browser_cdp_check,
|
||||
emoji="🧪",
|
||||
)
|
||||
+4
-4
@@ -43,7 +43,7 @@ _HERMES_CORE_TOOLS = [
|
||||
"browser_navigate", "browser_snapshot", "browser_click",
|
||||
"browser_type", "browser_scroll", "browser_back",
|
||||
"browser_press", "browser_get_images",
|
||||
"browser_vision", "browser_console",
|
||||
"browser_vision", "browser_console", "browser_cdp",
|
||||
# Text-to-speech
|
||||
"text_to_speech",
|
||||
# Planning & memory
|
||||
@@ -115,7 +115,7 @@ TOOLSETS = {
|
||||
"browser_navigate", "browser_snapshot", "browser_click",
|
||||
"browser_type", "browser_scroll", "browser_back",
|
||||
"browser_press", "browser_get_images",
|
||||
"browser_vision", "browser_console", "web_search"
|
||||
"browser_vision", "browser_console", "browser_cdp", "web_search"
|
||||
],
|
||||
"includes": []
|
||||
},
|
||||
@@ -249,7 +249,7 @@ TOOLSETS = {
|
||||
"browser_navigate", "browser_snapshot", "browser_click",
|
||||
"browser_type", "browser_scroll", "browser_back",
|
||||
"browser_press", "browser_get_images",
|
||||
"browser_vision", "browser_console",
|
||||
"browser_vision", "browser_console", "browser_cdp",
|
||||
"todo", "memory",
|
||||
"session_search",
|
||||
"execute_code", "delegate_task",
|
||||
@@ -274,7 +274,7 @@ TOOLSETS = {
|
||||
"browser_navigate", "browser_snapshot", "browser_click",
|
||||
"browser_type", "browser_scroll", "browser_back",
|
||||
"browser_press", "browser_get_images",
|
||||
"browser_vision", "browser_console",
|
||||
"browser_vision", "browser_console", "browser_cdp",
|
||||
# Planning & memory
|
||||
"todo", "memory",
|
||||
# Session history search
|
||||
|
||||
+23
-9
@@ -27,7 +27,7 @@ from tui_gateway.render import make_stream_renderer, render_diff, render_message
|
||||
|
||||
_sessions: dict[str, dict] = {}
|
||||
_methods: dict[str, callable] = {}
|
||||
_pending: dict[str, threading.Event] = {}
|
||||
_pending: dict[str, tuple[str, threading.Event]] = {}
|
||||
_answers: dict[str, str] = {}
|
||||
_db = None
|
||||
_stdout_lock = threading.Lock()
|
||||
@@ -296,7 +296,7 @@ def _enable_gateway_prompts() -> None:
|
||||
def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
|
||||
rid = uuid.uuid4().hex[:8]
|
||||
ev = threading.Event()
|
||||
_pending[rid] = ev
|
||||
_pending[rid] = (sid, ev)
|
||||
payload["request_id"] = rid
|
||||
_emit(event, sid, payload)
|
||||
ev.wait(timeout=timeout)
|
||||
@@ -304,10 +304,19 @@ def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
|
||||
return _answers.pop(rid, "")
|
||||
|
||||
|
||||
def _clear_pending():
|
||||
for rid, ev in list(_pending.items()):
|
||||
_answers[rid] = ""
|
||||
ev.set()
|
||||
def _clear_pending(sid: str | None = None) -> None:
|
||||
"""Release pending prompts with an empty answer.
|
||||
|
||||
When *sid* is provided, only prompts owned by that session are
|
||||
released — critical for session.interrupt, which must not
|
||||
collaterally cancel clarify/sudo/secret prompts on unrelated
|
||||
sessions sharing the same tui_gateway process. When *sid* is
|
||||
None, every pending prompt is released (used during shutdown).
|
||||
"""
|
||||
for rid, (owner_sid, ev) in list(_pending.items()):
|
||||
if sid is None or owner_sid == sid:
|
||||
_answers[rid] = ""
|
||||
ev.set()
|
||||
|
||||
|
||||
# ── Agent factory ────────────────────────────────────────────────────
|
||||
@@ -1345,7 +1354,11 @@ def _(rid, params: dict) -> dict:
|
||||
return err
|
||||
if hasattr(session["agent"], "interrupt"):
|
||||
session["agent"].interrupt()
|
||||
_clear_pending()
|
||||
# Scope the pending-prompt release to THIS session. A global
|
||||
# _clear_pending() would collaterally cancel clarify/sudo/secret
|
||||
# prompts on unrelated sessions sharing the same tui_gateway
|
||||
# process, silently resolving them to empty strings.
|
||||
_clear_pending(params.get("session_id", ""))
|
||||
try:
|
||||
from tools.approval import resolve_gateway_approval
|
||||
resolve_gateway_approval(session["session_key"], "deny", resolve_all=True)
|
||||
@@ -1684,9 +1697,10 @@ def _(rid, params: dict) -> dict:
|
||||
|
||||
def _respond(rid, params, key):
|
||||
r = params.get("request_id", "")
|
||||
ev = _pending.get(r)
|
||||
if not ev:
|
||||
entry = _pending.get(r)
|
||||
if not entry:
|
||||
return _err(rid, 4009, f"no pending {key} request")
|
||||
_, ev = entry
|
||||
_answers[r] = params.get(key, "")
|
||||
ev.set()
|
||||
return _ok(rid, {"status": "ok"})
|
||||
|
||||
@@ -1052,11 +1052,11 @@ custom_providers:
|
||||
# api_key omitted — Hermes uses "no-key-required" for keyless local servers
|
||||
- name: work
|
||||
base_url: https://gpu-server.internal.corp/v1
|
||||
api_key: corp-api-key
|
||||
key_env: CORP_API_KEY
|
||||
api_mode: chat_completions # optional, auto-detected from URL
|
||||
- name: anthropic-proxy
|
||||
base_url: https://proxy.example.com/anthropic
|
||||
api_key: proxy-key
|
||||
key_env: ANTHROPIC_PROXY_KEY
|
||||
api_mode: anthropic_messages # for Anthropic-compatible proxies
|
||||
```
|
||||
|
||||
@@ -1154,7 +1154,7 @@ fallback_model:
|
||||
provider: openrouter # required
|
||||
model: anthropic/claude-sonnet-4 # required
|
||||
# base_url: http://localhost:8000/v1 # optional, for custom endpoints
|
||||
# api_key_env: MY_CUSTOM_KEY # optional, env var name for custom endpoint API key
|
||||
# key_env: MY_CUSTOM_KEY # optional, env var name for custom endpoint API key
|
||||
```
|
||||
|
||||
When activated, the fallback swaps the model and provider mid-session without losing your conversation. It fires **at most once** per session.
|
||||
@@ -1178,7 +1178,7 @@ smart_model_routing:
|
||||
provider: openrouter
|
||||
model: google/gemini-2.5-flash
|
||||
# base_url: http://localhost:8000/v1 # optional custom endpoint
|
||||
# api_key_env: MY_CUSTOM_KEY # optional env var name for that endpoint's API key
|
||||
# key_env: MY_CUSTOM_KEY # optional env var name for that endpoint's API key
|
||||
```
|
||||
|
||||
How it works:
|
||||
|
||||
@@ -6,9 +6,9 @@ description: "Authoritative reference for Hermes built-in tools, grouped by tool
|
||||
|
||||
# Built-in Tools Reference
|
||||
|
||||
This page documents all 52 built-in tools in the Hermes tool registry, grouped by toolset. Availability varies by platform, credentials, and enabled toolsets.
|
||||
This page documents all 53 built-in tools in the Hermes tool registry, grouped by toolset. Availability varies by platform, credentials, and enabled toolsets.
|
||||
|
||||
**Quick counts:** 10 browser tools, 4 file tools, 10 RL tools, 4 Home Assistant tools, 2 terminal tools, 2 web tools, 5 Feishu tools, and 15 standalone tools across other toolsets.
|
||||
**Quick counts:** 11 browser tools, 4 file tools, 10 RL tools, 4 Home Assistant tools, 2 terminal tools, 2 web tools, 5 Feishu tools, and 15 standalone tools across other toolsets.
|
||||
|
||||
:::tip MCP Tools
|
||||
In addition to built-in tools, Hermes can load tools dynamically from MCP servers. MCP tools appear with a server-name prefix (e.g., `github_create_issue` for the `github` MCP server). See [MCP Integration](/docs/user-guide/features/mcp) for configuration.
|
||||
@@ -19,6 +19,7 @@ In addition to built-in tools, Hermes can load tools dynamically from MCP server
|
||||
| Tool | Description | Requires environment |
|
||||
|------|-------------|----------------------|
|
||||
| `browser_back` | Navigate back to the previous page in browser history. Requires browser_navigate to be called first. | — |
|
||||
| `browser_cdp` | Send a raw Chrome DevTools Protocol (CDP) command. Escape hatch for browser operations not covered by browser_navigate, browser_click, browser_console, etc. Only available when a CDP endpoint is reachable at session start — via `/browser connect` or `browser.cdp_url` config. See https://chromedevtools.github.io/devtools-protocol/ | — |
|
||||
| `browser_click` | Click on an element identified by its ref ID from the snapshot (e.g., '@e5'). The ref IDs are shown in square brackets in the snapshot output. Requires browser_navigate and browser_snapshot to be called first. | — |
|
||||
| `browser_console` | Get browser console output and JavaScript errors from the current page. Returns console.log/warn/error/info messages and uncaught JS exceptions. Use this to detect silent JavaScript errors, failed API calls, and application warnings. Requi… | — |
|
||||
| `browser_get_images` | Get a list of all images on the current page with their URLs and alt text. Useful for finding images to analyze with the vision tool. Requires browser_navigate to be called first. | — |
|
||||
|
||||
@@ -52,7 +52,7 @@ Or in-session:
|
||||
|
||||
| Toolset | Tools | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `browser` | `browser_back`, `browser_click`, `browser_console`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `web_search` | Full browser automation. Includes `web_search` as a fallback for quick lookups. |
|
||||
| `browser` | `browser_back`, `browser_cdp`, `browser_click`, `browser_console`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `web_search` | Full browser automation. Includes `web_search` as a fallback for quick lookups. `browser_cdp` is a raw CDP passthrough gated on a reachable CDP endpoint — it only appears when `/browser connect` is active or `browser.cdp_url` is set. |
|
||||
| `clarify` | `clarify` | Ask the user a question when the agent needs clarification. |
|
||||
| `code_execution` | `execute_code` | Run Python scripts that call Hermes tools programmatically. |
|
||||
| `cronjob` | `cronjob` | Schedule and manage recurring tasks. |
|
||||
|
||||
@@ -327,6 +327,36 @@ Check the browser console for any JavaScript errors
|
||||
|
||||
Use `clear=True` to clear the console after reading, so subsequent calls only show new messages.
|
||||
|
||||
### `browser_cdp`
|
||||
|
||||
Raw Chrome DevTools Protocol passthrough — the escape hatch for browser operations not covered by the other tools. Use for native dialog handling, iframe-scoped evaluation, cookie/network control, or any CDP verb the agent needs.
|
||||
|
||||
**Only available when a CDP endpoint is reachable at session start** — meaning `/browser connect` has attached to a running Chrome, or `browser.cdp_url` is set in `config.yaml`. The default local agent-browser mode, Camofox, and cloud providers (Browserbase, Browser Use, Firecrawl) do not currently expose CDP to this tool — cloud providers have per-session CDP URLs but live-session routing is a follow-up.
|
||||
|
||||
**CDP method reference:** https://chromedevtools.github.io/devtools-protocol/ — the agent can `web_extract` a specific method's page to look up parameters and return shape.
|
||||
|
||||
Common patterns:
|
||||
|
||||
```
|
||||
# List tabs (browser-level, no target_id)
|
||||
browser_cdp(method="Target.getTargets")
|
||||
|
||||
# Handle a native JS dialog on a tab
|
||||
browser_cdp(method="Page.handleJavaScriptDialog",
|
||||
params={"accept": true, "promptText": ""},
|
||||
target_id="<tabId>")
|
||||
|
||||
# Evaluate JS in a specific tab
|
||||
browser_cdp(method="Runtime.evaluate",
|
||||
params={"expression": "document.title", "returnByValue": true},
|
||||
target_id="<tabId>")
|
||||
|
||||
# Get all cookies
|
||||
browser_cdp(method="Network.getAllCookies")
|
||||
```
|
||||
|
||||
Browser-level methods (`Target.*`, `Browser.*`, `Storage.*`) omit `target_id`. Page-level methods (`Page.*`, `Runtime.*`, `DOM.*`, `Emulation.*`) require a `target_id` from `Target.getTargets`. Each call is independent — sessions do not persist between calls.
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Filling Out a Web Form
|
||||
|
||||
@@ -61,18 +61,18 @@ Both `provider` and `model` are **required**. If either is missing, the fallback
|
||||
| Arcee AI | `arcee` | `ARCEEAI_API_KEY` |
|
||||
| Alibaba / DashScope | `alibaba` | `DASHSCOPE_API_KEY` |
|
||||
| Hugging Face | `huggingface` | `HF_TOKEN` |
|
||||
| Custom endpoint | `custom` | `base_url` + `api_key_env` (see below) |
|
||||
| Custom endpoint | `custom` | `base_url` + `key_env` (see below) |
|
||||
|
||||
### Custom Endpoint Fallback
|
||||
|
||||
For a custom OpenAI-compatible endpoint, add `base_url` and optionally `api_key_env`:
|
||||
For a custom OpenAI-compatible endpoint, add `base_url` and optionally `key_env`:
|
||||
|
||||
```yaml
|
||||
fallback_model:
|
||||
provider: custom
|
||||
model: my-local-model
|
||||
base_url: http://localhost:8000/v1
|
||||
api_key_env: MY_LOCAL_KEY # env var name containing the API key
|
||||
key_env: MY_LOCAL_KEY # env var name containing the API key
|
||||
```
|
||||
|
||||
### When Fallback Triggers
|
||||
@@ -128,7 +128,7 @@ fallback_model:
|
||||
provider: custom
|
||||
model: llama-3.1-70b
|
||||
base_url: http://localhost:8000/v1
|
||||
api_key_env: LOCAL_API_KEY
|
||||
key_env: LOCAL_API_KEY
|
||||
```
|
||||
|
||||
**Codex OAuth as fallback:**
|
||||
|
||||
@@ -77,7 +77,7 @@ Cost and depth are controlled by three independent knobs:
|
||||
| Knob | Controls | Default |
|
||||
|------|----------|---------|
|
||||
| `contextCadence` | Turns between `context()` API calls (base layer refresh) | `1` |
|
||||
| `dialecticCadence` | Turns between `peer.chat()` LLM calls (dialectic layer refresh) | `3` |
|
||||
| `dialecticCadence` | Turns between `peer.chat()` LLM calls (dialectic layer refresh) | `2` (recommended 1–5) |
|
||||
| `dialecticDepth` | Number of `.chat()` passes per dialectic invocation (1–3) | `1` |
|
||||
|
||||
These are orthogonal — you can have frequent context refreshes with infrequent dialectic, or deep multi-pass dialectic at low frequency. Example: `contextCadence: 1, dialecticCadence: 5, dialecticDepth: 2` refreshes base context every turn, runs dialectic every 5 turns, and each dialectic run makes 2 passes.
|
||||
@@ -94,6 +94,14 @@ Each pass uses a proportional reasoning level (lighter early passes, base level
|
||||
|
||||
Passes bail out early if the prior pass returned strong signal (long, structured output), so depth 3 doesn't always mean 3 LLM calls.
|
||||
|
||||
### Session-Start Prewarm
|
||||
|
||||
On session init, Honcho fires a dialectic call in the background at the full configured `dialecticDepth` and hands the result directly to turn 1's context assembly. A single-pass prewarm on a cold peer often returns thin output — multi-pass depth runs the audit/reconcile cycle before the user ever speaks. If prewarm hasn't landed by turn 1, turn 1 falls back to a synchronous call with a bounded timeout.
|
||||
|
||||
### Query-Adaptive Reasoning Level
|
||||
|
||||
The auto-injected dialectic scales `dialecticReasoningLevel` by query length: +1 level at ≥120 chars, +2 at ≥400, clamped at `reasoningLevelCap` (default `"high"`). Disable with `reasoningHeuristic: false` to pin every auto call to `dialecticReasoningLevel`. Available levels: `minimal`, `low`, `medium`, `high`, `max`.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho.json` (profile-local). The setup wizard handles this for you.
|
||||
@@ -104,7 +112,7 @@ Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho
|
||||
|-----|---------|-------------|
|
||||
| `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Set to an integer (e.g. 1200) to cap. Truncates at word boundaries |
|
||||
| `contextCadence` | `1` | Minimum turns between `context()` API calls (base layer refresh) |
|
||||
| `dialecticCadence` | `3` | Minimum turns between `peer.chat()` LLM calls (dialectic layer). In `tools` mode, irrelevant — model calls explicitly |
|
||||
| `dialecticCadence` | `2` | Minimum turns between `peer.chat()` LLM calls (dialectic layer). Recommended 1–5. In `tools` mode, irrelevant — model calls explicitly |
|
||||
| `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped to 1–3 |
|
||||
| `dialecticDepthLevels` | `null` | Optional array of reasoning levels per pass, e.g. `["minimal", "low", "medium"]`. Overrides proportional defaults |
|
||||
| `dialecticReasoningLevel` | `'low'` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` |
|
||||
@@ -142,6 +150,41 @@ Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho
|
||||
|
||||
In `tools` mode, the model is fully in control — it calls `honcho_reasoning` when it wants, at whatever `reasoning_level` it picks. Cadence and budget settings only apply to modes with auto-injection (`hybrid` and `context`).
|
||||
|
||||
## Observation (Directional vs. Unified)
|
||||
|
||||
Honcho models a conversation as peers exchanging messages. Each peer has two observation toggles that map 1:1 to Honcho's `SessionPeerConfig`:
|
||||
|
||||
| Toggle | Effect |
|
||||
|--------|--------|
|
||||
| `observeMe` | Honcho builds a representation of this peer from its own messages |
|
||||
| `observeOthers` | This peer observes the other peer's messages (feeds cross-peer reasoning) |
|
||||
|
||||
Two peers × two toggles = four flags. `observationMode` is a shorthand preset:
|
||||
|
||||
| Preset | User flags | AI flags | Semantics |
|
||||
|--------|-----------|----------|-----------|
|
||||
| `"directional"` (default) | me: on, others: on | me: on, others: on | Full mutual observation. Enables cross-peer dialectic — "what does the AI know about the user, based on what the user said and the AI replied." |
|
||||
| `"unified"` | me: on, others: off | me: off, others: on | Shared-pool semantics — the AI observes the user's messages only, the user peer only self-models. Single-observer pool. |
|
||||
|
||||
Override the preset with an explicit `observation` block for per-peer control:
|
||||
|
||||
```json
|
||||
"observation": {
|
||||
"user": { "observeMe": true, "observeOthers": true },
|
||||
"ai": { "observeMe": true, "observeOthers": false }
|
||||
}
|
||||
```
|
||||
|
||||
Common patterns:
|
||||
|
||||
| Intent | Config |
|
||||
|--------|--------|
|
||||
| Full observation (most users) | `"observationMode": "directional"` |
|
||||
| AI shouldn't re-model the user from its own replies | `"ai": {"observeMe": true, "observeOthers": false}` |
|
||||
| Strong persona the AI peer shouldn't update from self-observation | `"ai": {"observeMe": false, "observeOthers": true}` |
|
||||
|
||||
Server-side toggles set via the [Honcho dashboard](https://app.honcho.dev) win over local defaults — Hermes syncs them back at session init.
|
||||
|
||||
## Tools
|
||||
|
||||
When Honcho is active as the memory provider, five tools become available:
|
||||
|
||||
@@ -82,7 +82,7 @@ hermes memory setup # select "honcho"
|
||||
| `workspace` | host key | Shared workspace ID |
|
||||
| `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Truncates at word boundaries |
|
||||
| `contextCadence` | `1` | Minimum turns between `context()` API calls (base layer refresh) |
|
||||
| `dialecticCadence` | `3` | Minimum turns between `peer.chat()` LLM calls. Only applies to `hybrid`/`context` modes |
|
||||
| `dialecticCadence` | `2` | Minimum turns between `peer.chat()` LLM calls. Recommended 1–5. Only applies to `hybrid`/`context` modes |
|
||||
| `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped 1–3. Pass 0: cold/warm prompt, pass 1: self-audit, pass 2: reconciliation |
|
||||
| `dialecticDepthLevels` | `null` | Optional array of reasoning levels per pass, e.g. `["minimal", "low", "medium"]`. Overrides proportional defaults |
|
||||
| `dialecticReasoningLevel` | `'low'` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` |
|
||||
@@ -140,23 +140,64 @@ hermes memory setup # select "honcho"
|
||||
If you previously used `hermes honcho setup`, your config and all server-side data are intact. Just re-enable through the setup wizard again or manually set `memory.provider: honcho` to reactivate via the new system.
|
||||
:::
|
||||
|
||||
**Multi-agent / Profiles:**
|
||||
**Multi-peer setup:**
|
||||
|
||||
Each Hermes profile gets its own Honcho AI peer while sharing the same workspace -- all profiles see the same user representation, but each agent builds its own identity and observations.
|
||||
Honcho models conversations as peers exchanging messages — one user peer plus one AI peer per Hermes profile, all sharing a workspace. The workspace is the shared environment: the user peer is global across profiles, each AI peer is its own identity. Every AI peer builds an independent representation / card from its own observations, so a `coder` profile stays code-oriented while a `writer` profile stays editorial against the same user.
|
||||
|
||||
The mapping:
|
||||
|
||||
| Concept | What it is |
|
||||
|---------|-----------|
|
||||
| **Workspace** | Shared environment. All Hermes profiles under one workspace see the same user identity. |
|
||||
| **User peer** (`peerName`) | The human. Shared across profiles in the workspace. |
|
||||
| **AI peer** (`aiPeer`) | One per Hermes profile. Host key `hermes` → default; `hermes.<profile>` for others. |
|
||||
| **Observation** | Per-peer toggles controlling what Honcho models from whose messages. `directional` (default, all four on) or `unified` (single-observer pool). |
|
||||
|
||||
### New profile, fresh Honcho peer
|
||||
|
||||
```bash
|
||||
hermes profile create coder --clone # creates honcho peer "coder", inherits config from default
|
||||
hermes profile create coder --clone
|
||||
```
|
||||
|
||||
What `--clone` does: creates a `hermes.coder` host block in `honcho.json` with `aiPeer: "coder"`, shared `workspace`, inherited `peerName`, `recallMode`, `writeFrequency`, `observation`, etc. The peer is eagerly created in Honcho so it exists before first message.
|
||||
`--clone` creates a `hermes.coder` host block in `honcho.json` with `aiPeer: "coder"`, shared `workspace`, inherited `peerName`, `recallMode`, `writeFrequency`, `observation`, etc. The AI peer is eagerly created in Honcho so it exists before the first message.
|
||||
|
||||
For profiles created before Honcho was set up:
|
||||
### Existing profiles, backfill Honcho peers
|
||||
|
||||
```bash
|
||||
hermes honcho sync # scans all profiles, creates host blocks for any missing ones
|
||||
hermes honcho sync
|
||||
```
|
||||
|
||||
This inherits settings from the default `hermes` host block and creates new AI peers for each profile. Idempotent -- skips profiles that already have a host block.
|
||||
Scans every Hermes profile, creates host blocks for any profile without one, inherits settings from the default `hermes` block, and creates the new AI peers eagerly. Idempotent — skips profiles that already have a host block.
|
||||
|
||||
### Per-profile observation
|
||||
|
||||
Each host block can override the observation config independently. Example: a code-focused profile where the AI peer observes the user but doesn't self-model:
|
||||
|
||||
```json
|
||||
"hermes.coder": {
|
||||
"aiPeer": "coder",
|
||||
"observation": {
|
||||
"user": { "observeMe": true, "observeOthers": true },
|
||||
"ai": { "observeMe": false, "observeOthers": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Observation toggles (one set per peer):**
|
||||
|
||||
| Toggle | Effect |
|
||||
|--------|--------|
|
||||
| `observeMe` | Honcho builds a representation of this peer from its own messages |
|
||||
| `observeOthers` | This peer observes the other peer's messages (feeds cross-peer reasoning) |
|
||||
|
||||
Presets via `observationMode`:
|
||||
|
||||
- **`"directional"`** (default) — all four flags on. Full mutual observation; enables cross-peer dialectic.
|
||||
- **`"unified"`** — user `observeMe: true`, AI `observeOthers: true`, rest false. Single-observer pool; AI models the user but not itself, user peer only self-models.
|
||||
|
||||
Server-side toggles set via the [Honcho dashboard](https://app.honcho.dev) win over local defaults — synced back at session init.
|
||||
|
||||
See the [Honcho page](./honcho.md#observation-directional-vs-unified) for the full observation reference.
|
||||
|
||||
<details>
|
||||
<summary>Full honcho.json example (multi-profile)</summary>
|
||||
@@ -181,7 +222,7 @@ This inherits settings from the default `hermes` host block and creates new AI p
|
||||
},
|
||||
"dialecticReasoningLevel": "low",
|
||||
"dialecticDynamic": true,
|
||||
"dialecticCadence": 3,
|
||||
"dialecticCadence": 2,
|
||||
"dialecticDepth": 1,
|
||||
"dialecticMaxChars": 600,
|
||||
"contextCadence": 1,
|
||||
|
||||
Reference in New Issue
Block a user