Commit 4a9c3565 added a reference to `self.config` in
`_check_compression_model_feasibility()` to pass the user-configured
`auxiliary.compression.context_length` to `get_model_context_length()`.
However, `AIAgent` never stores the loaded config dict as an instance
attribute — the config is loaded into a local variable `_agent_cfg` in
`__init__()` and discarded after init.
This causes an `AttributeError: 'AIAgent' object has no attribute
'config'` on every session start when compression is enabled, caught by
the try/except and logged as a non-fatal DEBUG message.
Fix: store the loaded config as `self._config` in `__init__()` and
update the reference in the feasibility check to use `self._config`.
Previously the queue only drained inside the message.complete event
handler, so anything enqueued while a shell.exec (!sleep, !cmd) or a
failed agent turn was running would stay stuck forever — neither of
those paths emits message.complete. After Ctrl+C an interrupted
session would also orphan the queue because idle() flips busy=false
locally without going through message.complete.
Single source of truth: a useEffect that watches ui.busy. When the
session is settled (sid present, busy false, not editing a queue
item), pull one message and send it. Covers agent turn end,
interrupt, shell.exec completion, error recovery, and the original
startup hydration (first-sid case) all at once.
Dropped the now-redundant dequeue/sendQueued from
createGatewayEventHandler.message.complete and the accompanying
GatewayEventHandlerContext.composer field — the effect handles it.
- providers.ts: drop the `dup` intermediate, fold the ternary inline
- paths.ts (fmtCwdBranch): inline `b` into the `tag` template
- prompts.tsx (ConfirmPrompt): hoist a single `lower = ch.toLowerCase()`,
collapse the three early-return branches into two, drop the
redundant bounds checks on arrow-key handlers (setSel is idempotent
at 0/1), inline the `confirmLabel`/`cancelLabel` defaults at the
use site
- modelPicker.tsx / config/env.ts / providers.test.ts: auto-formatter
reflows picked up by `npm run fix`
- useInputHandlers.ts: drop the stray blank line that was tripping
perfectionist/sort-imports (pre-existing lint error)
The stdin-read loop in entry.py calls handle_request() inline, so the
five handlers that can block for seconds to minutes
(slash.exec, cli.exec, shell.exec, session.resume, session.branch)
freeze the dispatcher. While one is running, any inbound RPC —
notably approval.respond and session.interrupt — sits unread in the
pipe buffer and lands only after the slow handler returns.
Route only those five onto a small ThreadPoolExecutor; every other
handler stays on the main thread so the fast-path ordering is
unchanged and the audit surface stays small. write_json is already
_stdout_lock-guarded, so concurrent response writes are safe. Pool
size defaults to 4 (overridable via HERMES_TUI_RPC_POOL_WORKERS).
- add _LONG_HANDLERS set + ThreadPoolExecutor + atexit shutdown
- new dispatch(req) function: pool for long handlers, inline for rest
- _run_and_emit wraps pool work in a try/except so a misbehaving
handler still surfaces as a JSON-RPC error instead of silently
dying in a worker
- entry.py swaps handle_request → dispatch
- 5 new tests: sync path still inline, long handlers emit via stdout,
fast handler not blocked behind slow one, handler exceptions map to
error responses, non-long methods always take the sync path
Manual repro confirms the fix: shell.exec(sleep 3) + terminal.resize
sent back-to-back now returns the resize response at t=0s while the
sleep finishes independently at t=3s. Before, both landed together
at t=3s.
Fixes#12546.
Two small races in gateway/platforms/discord.py, bundled together
since they're adjacent in the adapter and both narrow in impact.
1. on_message vs _resolve_allowed_usernames (startup window)
DISCORD_ALLOWED_USERS accepts both numeric IDs and raw usernames.
At connect-time, _resolve_allowed_usernames walks the bot's guilds
(fetch_members can take multiple seconds) to swap usernames for IDs.
on_message can fire during that window; _is_allowed_user compares
the numeric author.id against a set that may still contain raw
usernames — legitimate users get silently rejected for a few
seconds after every reconnect.
Fix: on_message awaits _ready_event (with a 30s timeout) when it
isn't already set. on_ready sets the event after the resolve
completes. In steady state this is a no-op (event already set);
only the startup / reconnect window ever blocks.
2. join_voice_channel check-and-connect
The existing-connection check at _voice_clients.get() and the
channel.connect() call straddled an await boundary with no lock.
Two concurrent /voice channel invocations could both see None and
both call connect(); discord.py raises ClientException
("Already connected") on the loser. Same race class for leave
running concurrently with _voice_timeout_handler.
Fix: per-guild asyncio.Lock (_voice_locks dict with lazy alloc via
_voice_lock_for). join_voice_channel and leave_voice_channel both
run their body under the lock. Sequential within a guild, still
fully concurrent across guilds.
Both: LOW severity. The first only affects username-based allowlists
on fast-follow-up messages at startup; the second is a narrow
exception on simultaneous voice commands. Bundled so the adapter
gets a single coherent polish pass.
Tests (tests/gateway/test_discord_race_polish.py): 2 regression cases.
- test_concurrent_joins_do_not_double_connect: two concurrent
join_voice_channel calls on the same guild result in exactly one
channel.connect() invocation.
- test_on_message_blocks_until_ready_event_set: asserts the expected
wait pattern is present in on_message (source inspection, since
full discord.py client setup isn't practical here).
Regression-guard validated: against unpatched gateway/platforms/discord.py
both tests fail. With the fix they pass. Full Discord suite (118
tests) green.
When a user hits /new or /resume before the previous session finishes
initializing, session.close runs while the previous session.create's
_build thread is still constructing the agent. session.close pops
_sessions[sid] and closes whatever slash_worker it finds (None at that
point — _build hasn't installed it yet), then returns. _build keeps
running in the background, installs the slash_worker subprocess and
registers an approval-notify callback on a session dict that's now
unreachable via _sessions. The subprocess leaks until process exit;
the notify callback lingers in the global registry.
Fix: _build now tracks what it allocates (worker, notify_registered)
and checks in its finally block whether _sessions[sid] still points
to the session it's building for. If not, the build was orphaned by
a racing close, so clean up the subprocess and unregister the notify
ourselves.
tui_gateway/server.py:
- _build reads _sessions.get(sid) safely (returns early if already gone)
- tracks allocated worker + notify registration
- finally checks orphan status and cleans up
Tests (tests/test_tui_gateway_server.py): 2 new cases.
- test_session_create_close_race_does_not_orphan_worker: slow
_make_agent, close mid-build, verify worker.close() and
unregister_gateway_notify both fire from the build thread's
cleanup path.
- test_session_create_no_race_keeps_worker_alive: regression guard —
happy path does NOT over-eagerly clean up a live worker.
Validated: against the unpatched code, the race test fails with
'orphan worker was not cleaned up — closed_workers=[]'. Live E2E
against the live Python environment confirmed the cleanup fires
exactly when the race happens.
Adds two complementary GitHub PR review guides from contest submissions:
- Cron-based PR review agent (from PR #5836 by @dieutx) — polls on a
schedule, no server needed, teaches skills + memory authoring
- Webhook-based PR review (from PR #6503 by @gaijinkush) — real-time via
GitHub webhooks, documents previously undocumented webhook feature
Both guides are cross-linked so users can pick the approach that fits.
Reworks quickstart.md by integrating the best content from PR #5744
by @aidil2105:
- Opinionated decision table ('The fastest path')
- Common failure modes table with causes and fixes
- Recovery toolkit sequence
- Session lifecycle verification step
- Better first-chat guidance with example prompts
Slims down installation.md:
- Removes 10-step manual/dev install section (already covered in
developer-guide/contributing.md)
- Links to Contributing guide for dev setup
- Keeps focused on the automated installer + prerequisites + troubleshooting
agent.switch_model() mutates self.model, self.provider, self.base_url,
self.api_key, self.api_mode, and rebuilds self.client / self._anthropic_client
in place. The worker thread running agent.run_conversation reads those
fields on every iteration. A concurrent config.set key=model or slash-
worker-mirrored /model / /personality / /prompt / /compress can send an
HTTP request with mismatched model + base_url (or the old client keeps
running against a new endpoint) — 400/404s the user never asked for.
Fix: same pattern as the session.undo / session.compress guards
(PR #12416) and the gateway runner's running-agent /model guard (PR
#12334). Reject with 4009 'session busy' when session.running is True.
Two call sites guarded:
- config.set with key=model: primary /model entry point from Ink
- _mirror_slash_side_effects for model / personality / prompt /
compress: slash-worker passthrough path that applies live-agent
side effects
Idle sessions still switch models normally — regression guard test
verifies this.
Tests (tests/test_tui_gateway_server.py): 4 new cases.
- test_config_set_model_rejects_while_running
- test_config_set_model_allowed_when_idle (regression guard)
- test_mirror_slash_side_effects_rejects_mutating_commands_while_running
- test_mirror_slash_side_effects_allowed_when_idle (regression guard)
Validated: against unpatched server.py, the two 'rejects_while_running'
tests fail with the exact race they assert against. With the fix all
4 pass. Live E2E against the live Python environment confirmed both
guards enforce 4009 / 'session busy' exactly as designed.
find-nearby and the (new) maps optional skill both used OpenStreetMap's
Overpass + Nominatim to answer the same question — 'what's near this
location?' — so shipping both would be duplicate code for overlapping
capability. Consolidate into one active-by-default skill at
skills/productivity/maps/ that is a strict superset of find-nearby.
Moves + deletions:
- optional-skills/productivity/maps/ → skills/productivity/maps/ (active,
no install step needed)
- skills/leisure/find-nearby/ → DELETED (fully superseded)
Upgrades to maps_client.py so it covers everything find-nearby did:
- Overpass server failover — tries overpass-api.de then
overpass.kumi.systems so a single-mirror outage doesn't break the skill
(new overpass_query helper, used by both nearby and bbox)
- nearby now accepts --near "<address>" as a shortcut that auto-geocodes,
so one command replaces the old 'search → copy coords → nearby' chain
- nearby now accepts --category (repeatable) for multi-type queries in
one call (e.g. --category restaurant --category bar), results merged
and deduped by (osm_type, osm_id), sorted by distance, capped at --limit
- Each nearby result now includes maps_url (clickable Google Maps search
link) and directions_url (Google Maps directions from the search point
— only when a ref point is known)
- Promoted commonly-useful OSM tags to top-level fields on each result:
cuisine, hours (opening_hours), phone, website — instead of forcing
callers to dig into the raw tags dict
SKILL.md:
- Version bumped 1.1.0 → 1.2.0, description rewritten to lead with
capability surface
- New 'Working With Telegram Location Pins' section replacing
find-nearby's equivalent workflow
- metadata.hermes.supersedes: [find-nearby] so tooling can flag any
lingering references to the old skill
External references updated:
- optional-skills/productivity/telephony/SKILL.md — related_skills
find-nearby → maps
- website/docs/reference/skills-catalog.md — removed the (now-empty)
'leisure' section, added 'maps' row under productivity
- website/docs/user-guide/features/cron.md — find-nearby example
usages swapped to maps
- tests/tools/test_cronjob_tools.py, tests/hermes_cli/test_cron.py,
tests/cron/test_scheduler.py — fixture string values swapped
- cli.py:5290 — /cron help-hint example swapped
Not touched:
- RELEASE_v0.2.0.md — historical record, left intact
E2E-verified live (Nominatim + Overpass, one query each):
- nearby --near "Times Square" --category restaurant --category bar → 3 results,
sorted by distance, all with maps_url, directions_url, cuisine, phone, website
where OSM had the tags
All 111 targeted tests pass across tests/cron/, tests/tools/, tests/hermes_cli/.
Adds a maps optional skill with 8 commands, 44 POI categories, and
zero external dependencies. Uses free open data: Nominatim, Overpass
API, OSRM, and TimeAPI.io.
Commands: search, reverse, nearby, distance, directions, timezone,
area, bbox.
Improvements over original PR #2015:
- Fixed directory structure (optional-skills/productivity/maps/)
- Fixed distance argparse (--to flag instead of broken dual nargs=+)
- Fixed timezone (TimeAPI.io instead of broken worldtimeapi heuristic)
- Expanded POI categories from 12 to 44
- Added directions command with turn-by-turn OSRM steps
- Added area command (bounding box + dimensions for a named place)
- Added bbox command (POI search within a geographic rectangle)
- Added 23 unit tests
- Improved haversine (atan2 for numerical stability)
- Comprehensive SKILL.md with workflow examples
Co-authored-by: Mibayy <Mibayy@users.noreply.github.com>
External services can now push plain-text notifications to a user's chat
via the webhook adapter without invoking the agent. Set deliver_only=true
on a route and the rendered prompt template becomes the literal message
body — dispatched directly to the configured target (Telegram, Discord,
Slack, GitHub PR comment, etc.).
Reuses all existing webhook infrastructure: HMAC-SHA256 signature
validation, per-route rate limiting, idempotency cache, body-size limits,
template rendering with dot-notation, home-channel fallback. No new HTTP
server, no new auth scheme, no new port.
Use cases: Supabase/Firebase webhooks → user notifications, monitoring
alert forwarding, inter-agent pings, background job completion alerts.
Changes:
- gateway/platforms/webhook.py: new _direct_deliver() helper + early
dispatch branch in _handle_webhook when deliver_only=true. Startup
validation rejects deliver_only with deliver=log.
- hermes_cli/main.py + hermes_cli/webhook.go: --deliver-only flag on
subscribe; list/show output marks direct-delivery routes.
- website/docs/user-guide/messaging/webhooks.md: new Direct Delivery
Mode section with config example, CLI example, response codes.
- skills/devops/webhook-subscriptions/SKILL.md: document --deliver-only
with use cases (bumped to v1.1.0).
- tests/gateway/test_webhook_deliver_only.py: 14 new tests covering
agent bypass, template rendering, status codes, HMAC still enforced,
idempotency still applies, rate limit still applies, startup
validation, and direct-deliver dispatch.
Validation: 78 webhook tests pass (64 existing + 14 new). E2E verified
with real aiohttp server + real urllib POST — agent not invoked, target
adapter.send() called with rendered template, duplicate delivery_id
suppressed.
Closes the gap identified in PR #12117 (thanks to @H1an1 / Antenna team)
without adding a second HTTP ingress server.
Follow-up on top of the helix4u #12388 cherry-picks:
- make deferred post-delivery callbacks generation-aware end-to-end so
stale runs cannot clear callbacks registered by a fresher run for the
same session
- bind callback ownership to the active session event at run start and
snapshot that generation inside base adapter processing so later event
mutation cannot retarget cleanup
- pass run_generation through proxy mode and drop stale proxy streams /
final results the same way local runs are dropped
- centralize stop/new interrupt cleanup into one helper and replace the
open-coded branches with shared logic
- unify internal control interrupt reason strings via shared constants
- remove the return from base.py's finally block so cleanup no longer
swallows cancellation/exception flow
- add focused regressions for generation forwarding, proxy stale
suppression, and newer-callback preservation
This addresses all review findings from the initial #12388 review while
keeping the fix scoped to stale-output/typing-loop interrupt handling.
Follow-up on top of the helix4u #6392 cherry-pick:
- reuse one helper for actionable Docker-local file-not-found errors
across document/image/video/audio local-media send paths
- include /outputs/... alongside /output/... in the container-local
path hint
- soften the gateway startup warning so it does not imply custom
host-visible mounts are broken; the warning now targets the specific
risky pattern of emitting container-local MEDIA paths without an
explicit export mount
- add focused regressions for /outputs/... and non-document media hint
coverage
This keeps the salvage aligned with the actual MEDIA delivery problem on
current main while reducing false-positive operator messaging.
When _send_fallback_final() is called with nothing new to deliver
(the visible partial already matches final_text), the last edit may
still show the cursor character because fallback mode was entered
after a failed edit. Before this fix the early-return path left
_already_sent = True without attempting to strip the cursor, so the
message stayed frozen with a visible ▉ permanently.
Adds a best-effort edit inside the empty-continuation branch to clean
the cursor off the last-sent text. Harmless when fallback mode
wasn't actually armed or when the cursor isn't present. If the strip
edit itself fails (flood still active), we return without crashing
and without corrupting _last_sent_text.
Adapted from PR #7429 onto current main — the surrounding fallback
block grew the #10807 stale-prefix handling since #7429 was written,
so the cursor strip lives in the new else-branch where we still
return early.
3 unit tests covering: cursor stripped on empty continuation, no edit
attempted when cursor is not configured, cursor-strip edit failure
handled without crash.
Originally proposed as PR #7429.
During gateway shutdown, a message arriving while
cancel_background_tasks is mid-await (inside asyncio.gather) spawns
a fresh _process_message_background task via handle_message and adds
it to self._background_tasks. The original implementation's
_background_tasks.clear() at the end of cancel_background_tasks
dropped the reference; the task ran untracked against a disconnecting
adapter, logged send-failures, and lingered until it completed on
its own.
Fix: wrap the cancel+gather in a bounded loop (MAX_DRAIN_ROUNDS=5).
If new tasks appeared during the gather, cancel them in the next
round. The .clear() at the end is preserved as a safety net for
any task that appeared after MAX_DRAIN_ROUNDS — but in practice the
drain stabilizes in 1-2 rounds.
Tests: tests/gateway/test_cancel_background_drain.py — 3 cases.
- test_cancel_background_tasks_drains_late_arrivals: spawn M1, start
cancel, inject M2 during M1's shielded cleanup, verify M2 is
cancelled.
- test_cancel_background_tasks_handles_no_tasks: no-op path still
terminates cleanly.
- test_cancel_background_tasks_bounded_rounds: baseline — single
task cancels in one round, loop terminates.
Regression-guard validated: against the unpatched implementation,
the late-arrival test fails with exactly the expected message
('task leaked'). With the fix it passes.
Blast radius is shutdown-only; the audit classified this as MED.
Shipping because the fix is small and the hygiene is worth it.
While investigating the audit's other MEDs (busy-handler double-ack,
Discord ExecApprovalView double-resolve, UpdatePromptView
double-resolve), I verified all three were false positives — the
check-and-set patterns have no await between them, so they're
atomic on single-threaded asyncio. No fix needed for those.
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.
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
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.
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)
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.
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).
The cherry-picked commit from #11434 uses the 154585401+ prefixed
noreply format. Add it alongside the existing bare entry so the
contributor audit passes.
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.
- 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.
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.
- 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
- 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)