Compare commits

..

21 Commits

Author SHA1 Message Date
alt-glitch f7aa8b1545 ci: add path filters to Docker and test workflows, remove supply chain audit
- Docker build only triggers on main push (code/config changes) and
  releases, no longer on every PR
- Tests skip markdown-only and docs-only changes
- Remove supply-chain-audit workflow
2026-04-20 01:39:39 +05:30
konsisumer 1d1e1277e4 fix(gateway): flush undelivered tail before segment reset to preserve streamed text (#8124)
When a streaming edit fails mid-stream (flood control, transport error)
and a tool boundary arrives before the fallback threshold is reached,
the pre-boundary tail in `_accumulated` was silently discarded by
`_reset_segment_state`. The user saw a frozen partial message and
missing words on the other side of the tool call.

Flush the undelivered tail as a continuation message before the reset,
computed relative to the last successfully-delivered prefix so we don't
duplicate content the user already saw.
2026-04-19 01:43:04 -07:00
Teknium e017131403 feat(cron): add wakeAgent gate — scripts can skip the agent entirely
Extends the existing cron script hook with a wake gate ported from
nanoclaw #1232. When a cron job's pre-check Python script (already
sandboxed to HERMES_HOME/scripts/) writes a JSON line like
```json
{"wakeAgent": false}
```
on its last stdout line, `run_job()` returns the SILENT marker and
skips the agent entirely — no LLM call, no delivery, no tokens spent.
Useful for frequent polls (every 1-5 min) that only need to wake the
agent when something has genuinely changed.

Any other script output (non-JSON, missing key, non-dict, `wakeAgent: true`,
truthy/falsy non-False values) behaves as before: stdout is injected
as context and the agent runs normally. Strict `False` is required
to skip — avoids accidental gating from arbitrary JSON.

Refactor:
- New pure helper `_parse_wake_gate(script_output)` in cron/scheduler.py
- `_build_job_prompt` accepts optional `prerun_script` tuple so the
  script runs exactly once per job (run_job runs it for the gate check,
  reuses the output for prompt injection)
- `run_job` short-circuits with SILENT_MARKER when gate fires

Script failures (success=False) still cannot trigger the gate — the
failure is reported as context to the agent as before.

This replaces the approach in closed PR #3837, which inlined bash
scripts via tempfile and lost the path-traversal/scripts-dir sandbox
that main's impl has. The wake-gate idea (the one net-new capability)
is ported on top of the existing sandboxed Python-script model.

Tests:
- 11 pure unit tests for _parse_wake_gate (empty, whitespace, non-JSON,
  non-dict JSON, missing key, truthy/falsy non-False, multi-line,
  trailing blanks, non-last-line JSON)
- 5 integration tests for run_job wake-gate (skip returns SILENT,
  wake-true passes through, script-runs-only-once, script failure
  doesn't gate, no-script regression)
- Full tests/cron/ suite: 194/194 pass
2026-04-19 01:42:35 -07:00
helix4u c94d26c69b fix(cli): sanitize interactive command output 2026-04-19 01:16:34 -07:00
kshitijk4poor 175cf7e6bb fix: tighten quiet-mode salvage follow-ups
Follow-up for the helix4u easy-fix salvage batch:
- route remaining context-engine quiet-mode output through
  _should_emit_quiet_tool_messages() so non-CLI/library callers stay
  silent consistently
- drop the extra senderAliases computation from WhatsApp allowlist-drop
  logging and remove the now-unused import

This keeps the batch scoped to the intended fixes while avoiding
leaked quiet-mode output and unnecessary duplicate work in the bridge.
2026-04-19 00:28:25 -07:00
helix4u cd59af17cc fix(agent): silence quiet_mode in python library use 2026-04-19 00:28:25 -07:00
helix4u 361675018f fix(setup): stop hardcoding max-iterations copy 2026-04-19 00:28:25 -07:00
helix4u 3ade655999 fix(whatsapp): log allowlist drops in bridge 2026-04-19 00:28:25 -07:00
Teknium 7c10761dd2 fix(discord): shield text-batch flush from follow-up cancel (#12444)
When Discord splits a long message at 2000 chars, _enqueue_text_event
buffers each chunk and schedules a _flush_text_batch task with a
short delay.  If another chunk lands while the prior flush task is
already inside handle_message, _enqueue_text_event calls
prior_task.cancel() — and without asyncio.shield, CancelledError
propagates from the flush task into handle_message → the agent's
streaming request, aborting the response the user was waiting on.

Reproducer: user sends a 3000-char prompt (split by Discord into 2
messages).  Chunk 1 lands, flush delay starts, chunk 2 lands during
the brief window when chunk 1's flush has already committed to
handle_message.  Agent's current streaming response is cancelled
with CancelledError, user sees a truncated or missing reply.

Fix (gateway/platforms/discord.py):
- Wrap the handle_message call in asyncio.shield so the inner
  dispatch is protected from the outer task's cancel.
- Add an except asyncio.CancelledError clause so the outer task
  still exits cleanly when cancel lands during the sleep window
  (before the pop) — semantics for that path are unchanged.

The new flush task spawned by the follow-up chunk still handles its
own batch via the normal pending-message / active-session machinery
in base.py, so follow-ups are not lost.

Tests: tests/gateway/test_text_batching.py —
test_shield_protects_handle_message_from_cancel.  Tracks a distinct
first_handle_cancelled event so the assertion fails cleanly when the
shield is missing (verified by stashing the fix and re-running).

Live E2E on the live-loaded DiscordAdapter:
  first_handle_cancelled: False  (shield worked)
  first_handle_completed: True   (handle_message ran to completion)
2026-04-19 00:09:38 -07:00
Teknium dca439fe92 fix(tui): scope session.interrupt pending-prompt release to the calling session (#12441)
session.interrupt on session A was blast-resolving pending
clarify/sudo/secret prompts on ALL sessions sharing the same
tui_gateway process.  Other sessions' agent threads unblocked with
empty-string answers as if the user had cancelled — silent
cross-session corruption.

Root cause: _pending and _answers were globals keyed by random rid
with no record of the owning session.  _clear_pending() iterated
every entry, so the session.interrupt handler had no way to limit
the release to its own sid.

Fix:
- tui_gateway/server.py: _pending now maps rid to (sid, Event)
  tuples.  _clear_pending takes an optional sid argument and filters
  by owner_sid when provided.  session.interrupt passes the calling
  sid so unrelated sessions are untouched.  _clear_pending(None)
  remains the shutdown path for completeness.
- _block and _respond updated to pack/unpack the new tuple format.

Tests (tests/test_tui_gateway_server.py): 4 new cases.
- test_interrupt_only_clears_own_session_pending: two sessions with
  pending prompts, interrupting one must not release the other.
- test_interrupt_clears_multiple_own_pending: same-sid multi-prompt
  release works.
- test_clear_pending_without_sid_clears_all: shutdown path preserved.
- test_respond_unpacks_sid_tuple_correctly: _respond handles the
  tuple format.

Also updated tests/tui_gateway/test_protocol.py to use the new tuple
format for test_block_and_respond and test_clear_pending.

Live E2E against the live Python environment confirmed cross-session
isolation: interrupting sid_a released its own pending prompt without
touching sid_b's.  All 78 related tests pass.
2026-04-19 00:03:58 -07:00
Teknium ce410521b3 feat(browser): add browser_cdp raw DevTools Protocol passthrough (#12369)
Agents can now send arbitrary CDP commands to the browser. The tool is
gated on a reachable CDP endpoint at session start — it only appears in
the toolset when BROWSER_CDP_URL is set (from '/browser connect') or
'browser.cdp_url' is configured in config.yaml. Backends that don't
currently expose CDP to the Python side (Camofox, default local
agent-browser, cloud providers whose per-session cdp_url is not yet
surfaced) do not see the tool at all.

Tool schema description links to the CDP method reference at
https://chromedevtools.github.io/devtools-protocol/ so the agent can
web_extract specific method docs on demand.

Stateless per call. Browser-level methods (Target.*, Browser.*,
Storage.*) omit target_id. Page-level methods attach to the target
with flatten=true and dispatch the method on the returned sessionId.
Clean errors when the endpoint becomes unreachable mid-session or
the URL isn't a WebSocket.

Tests: 19 unit (mock CDP server + gate checks) + E2E against real
headless Chrome (Target.getTargets, Browser.getVersion,
Runtime.evaluate with target_id, Page.navigate + re-eval, bogus
method, bogus target_id, missing endpoint) + E2E of the check_fn
gate (tool hidden without CDP URL, visible with it, hidden again
after unset).
2026-04-19 00:03:10 -07:00
helix4u d66414a844 docs(custom-providers): use key_env in examples 2026-04-18 23:07:59 -07:00
helix4u 7b1a11b971 fix(memory): keep Honcho provider opt-in 2026-04-18 22:50:55 -07:00
kshitijk4poor 0a8d48809f chore: add LeonSGP43 numeric noreply email to AUTHOR_MAP
The cherry-picked commit from #11434 uses the 154585401+ prefixed
noreply format. Add it alongside the existing bare entry so the
contributor audit passes.
2026-04-18 22:50:55 -07:00
Erosika 21d5ef2f17 feat(honcho): wizard cadence default 2, surface reasoning level, backwards-compat fallback
Setup wizard now always writes dialecticCadence=2 on new configs and
surfaces the reasoning level as an explicit step with all five options
(minimal / low / medium / high / max), always writing
dialecticReasoningLevel.

Code keeps a backwards-compat fallback of 1 when dialecticCadence is
unset so existing honcho.json configs that predate the setting keep
firing every turn on upgrade. New setups via the wizard get 2
explicitly; docs show 2 as the default.

Also scrubs editorial lines from code and docs ("max is reserved for
explicit tool-path selection", "Unset → every turn; wizard pre-fills 2",
and similar process-exposing phrasing) and adds an inline link to
app.honcho.dev where the server-side observation sync is mentioned in
honcho.md. Recommended cadence range updated to 1-5 across docs and
wizard copy.
2026-04-18 22:50:55 -07:00
LeonSGP43 5b6792f04d fix(honcho): scope gateway sessions by runtime user id 2026-04-18 22:50:55 -07:00
Erosika ba7da73ca9 test(honcho): drop two first-turn tests subsumed by prewarm + smoke coverage
- TestDialecticDepth::test_first_turn_runs_dialectic_synchronously:
  covered by TestSessionStartDialecticPrewarm::test_turn1_falls_back_to_sync_when_prewarm_missing
  (more realistic — exercises the empty-prewarm → sync-fallback path)
- TestDialecticDepth::test_first_turn_dialectic_does_not_double_fire:
  covered by TestDialecticLifecycleSmoke (turn 1 flow) and
  TestDialecticCadenceAdvancesOnSuccess::test_empty_dialectic_result_does_not_advance_cadence

Both predate the prewarm refactor and test paths that are now
fallback behaviors already covered elsewhere.
2026-04-18 22:50:55 -07:00
Erosika c630dfcdac feat(honcho): dialectic liveness — stale-thread watchdog, stale-result discard, empty-streak backoff
Hardens the dialectic lifecycle against three failure modes that could
leave the prefetch pipeline stuck or injecting stale content:

- Stale-thread watchdog: _thread_is_live() treats any prefetch thread
  older than timeout × 2.0 as dead. A hung Honcho call can no longer
  block subsequent fires indefinitely.

- Stale-result discard: pending _prefetch_result is tagged with its
  fire turn. prefetch() discards the result if more than cadence × 2
  turns passed before a consumer read it (e.g. a run of trivial-prompt
  turns between fire and read).

- Empty-streak backoff: consecutive empty dialectic returns widen the
  effective cadence (dialectic_cadence + streak, capped at cadence × 8).
  A healthy fire resets the streak. Prevents the plugin from hammering
  the backend every turn when the peer graph is cold.

- liveness_snapshot() on the provider exposes current turn, last fire,
  pending fire-at, empty streak, effective cadence, and thread status
  for in-process diagnostics.

- system_prompt_block: nudge the model that honcho_reasoning accepts
  reasoning_level minimal/low/medium/high/max per call.

- hermes honcho status: surface base reasoning level, cap, and heuristic
  toggle so config drift is visible at a glance.

Tests: 550 passed.
- TestDialecticLiveness (8 tests): stale-thread recovery, stale-result
  discard, fresh-result retention, backoff widening, backoff ceiling,
  streak reset on success, streak increment on empty, snapshot shape.
- Existing TestDialecticCadenceAdvancesOnSuccess::test_in_flight_thread_is_not_stacked
  updated to set _prefetch_thread_started_at so it tests the
  fresh-thread-blocks branch (stale path covered separately).
- test_cli TestCmdStatus fake updated with the new config attrs surfaced
  in the status block.
2026-04-18 22:50:55 -07:00
Erosika 098efde848 docs(honcho): wizard cadence default 2, prewarm/depth + observation + multi-peer
- cli: setup wizard pre-fills dialecticCadence=2 (code default stays 1
  so unset → every turn)
- honcho.md: fix stale dialecticCadence default in tables, add
  Session-Start Prewarm subsection (depth runs at init), add
  Query-Adaptive Reasoning Level subsection, expand Observation
  section with directional vs unified semantics and per-peer patterns
- memory-providers.md: fix stale default, rename Multi-agent/Profiles
  to Multi-peer setup, add concrete walkthrough for new profiles and
  sync, document observation toggles + presets, link to honcho.md
- SKILL.md: fix stale defaults, add Depth at session start callout
2026-04-18 22:50:55 -07:00
Erosika 5f9907c116 chore(honcho): drop docs from PR scope, scrub commentary
- Revert website/docs and SKILL.md changes; docs unification handled separately
- Scrub commit/PR refs and process narration from code comments and test
  docstrings (no behavior change)
2026-04-18 22:50:55 -07:00
Erosika 78586ce036 fix(honcho): dialectic lifecycle — defaults, retry, prewarm consumption
Several correctness and cost-safety fixes to the Honcho dialectic path
after a multi-turn investigation surfaced a chain of silent failures:

- dialecticCadence default flipped 3 → 1. PR #10619 changed this from 1 to
  3 for cost, but existing installs with no explicit config silently went
  from per-turn dialectic to every-3-turns on upgrade. Restores pre-#10619
  behavior; 3+ remains available for cost-conscious setups. Docs + wizard
  + status output updated to match.

- Session-start prewarm now consumed. Previously fired a .chat() on init
  whose result landed in HonchoSessionManager._dialectic_cache and was
  never read — pop_dialectic_result had zero call sites. Turn 1 paid for
  a duplicate synchronous dialectic. Prewarm now writes directly to the
  plugin's _prefetch_result via _prefetch_lock so turn 1 consumes it with
  no extra call.

- Prewarm is now dialecticDepth-aware. A single-pass prewarm can return
  weak output on cold peers; the multi-pass audit/reconcile cycle is
  exactly the case dialecticDepth was built for. Prewarm now runs the
  full configured depth in the background.

- Silent dialectic failure no longer burns the cadence window.
  _last_dialectic_turn now advances only when the result is non-empty.
  Empty result → next eligible turn retries immediately instead of
  waiting the full cadence gap.

- Thread pile-up guard. queue_prefetch skips when a prior dialectic
  thread is still in-flight, preventing stacked races on _prefetch_result.

- First-turn sync timeout is recoverable. Previously on timeout the
  background thread's result was stored in a dead local list. Now the
  thread writes into _prefetch_result under lock so the next turn
  picks it up.

- Cadence gate applies uniformly. At cadence=1 the old "cadence > 1"
  guard let first-turn sync + same-turn queue_prefetch both fire.
  Gate now always applies.

- Restored query-length reasoning-level scaling, dropped in 9a0ab34c.
  Scales dialecticReasoningLevel up on longer queries (+1 at ≥120 chars,
  +2 at ≥400), clamped at reasoningLevelCap. Two new config keys:
  `reasoningHeuristic` (bool, default true) and `reasoningLevelCap`
  (string, default "high"; previously parsed but never enforced).
  Respects dialecticDepthLevels and proportional lighter-early passes.

- Restored short-prompt skip, dropped in ef7f3156. One-word
  acknowledgements ("ok", "y", "thanks") and slash commands bypass
  both injection and dialectic fire.

- Purged dead code in session.py: prefetch_dialectic, _dialectic_cache,
  set_dialectic_result, pop_dialectic_result — all unused after prewarm
  refactor.

Tests: 542 passed across honcho_plugin/, agent/test_memory_provider.py,
and run_agent/test_run_agent.py. New coverage:
- TestTrivialPromptHeuristic (classifier + prefetch/queue skip)
- TestDialecticCadenceAdvancesOnSuccess (empty-result retry, pile-up guard)
- TestSessionStartDialecticPrewarm (prewarm consumed, sync fallback)
- TestReasoningHeuristic (length bumps, cap clamp, interaction with depth)
- TestDialecticLifecycleSmoke (end-to-end 8-turn session walk)
2026-04-18 22:50:55 -07:00
44 changed files with 2762 additions and 799 deletions
+7 -2
View File
@@ -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]
-248
View File
@@ -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
+6
View File
@@ -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
+59 -52
View File
@@ -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 16k32k minimum. Tool schemas + system prompt alone use ~4k8k.[/]"
)
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
View File
@@ -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')}"
+14 -1
View File
@@ -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)
+48
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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 15 |
| `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 15) |
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.
+252 -53
View File
@@ -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
+27 -4
View File
@@ -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}")
+15
View File
@@ -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")
+13 -42
View File
@@ -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
View File
@@ -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).
+1
View File
@@ -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",
+8
View File
@@ -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;
}
-2
View File
@@ -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()
+56 -9
View File
@@ -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."""
+21
View File
@@ -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()
+14
View File
@@ -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")
+174
View File
@@ -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."""
+57 -2
View File
@@ -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):
+64
View File
@@ -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
-7
View File
@@ -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") == ""
+3
View File
@@ -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"
+617 -53
View File
@@ -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 56: 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 56 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()
+40
View File
@@ -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
+116
View File
@@ -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)
+408
View File
@@ -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
+5 -2
View File
@@ -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()
+416
View File
@@ -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
View File
@@ -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
View File
@@ -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"})
+4 -4
View File
@@ -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:
+3 -2
View File
@@ -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. | — |
+1 -1
View File
@@ -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:**
+45 -2
View File
@@ -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 15) |
| `dialecticDepth` | Number of `.chat()` passes per dialectic invocation (13) | `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 15. In `tools` mode, irrelevant — model calls explicitly |
| `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped to 13 |
| `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 15. Only applies to `hybrid`/`context` modes |
| `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped 13. 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,