Compare commits

...
Author SHA1 Message Date
ethernet 4312a9cc4e test ci 2026-06-12 15:02:05 -04:00
Austin PickettandCursor 021ed69141 docs: finish Automation Blueprints terminology rebrand (#44470)
* docs: finish Automation Blueprints terminology rebrand

Replace leftover "Automation Templates" wording from the Cron Recipes
rebrand, rename the copy-paste cookbook guide to Automation Recipes, and
point the marketing gallery link at the blueprints catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: use Automation Blueprints instead of Recipes in guide

Rename the cookbook guide from automation-recipes to
automation-blueprints so sidebar and copy match the product term.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: rename automation-blueprints-catalog to automation-blueprints

Drop the -catalog suffix from the reference page slug and title, and
move the copy-paste cookbook to automation-blueprint-examples so the
main Automation Blueprints doc is unambiguous.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Revert "docs: rename automation-blueprints-catalog to automation-blueprints"

This reverts commit 605f1eeab5.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 17:22:22 -04:00
Teknium 6c752ca3a5 refactor(agent): tighten SUMMARY_PREFIX wording and fix stale doc references
Legibility pass on the consolidated prefix: collapse the topic-overlap rule
from three overlapping sentences into one WINS sentence + one discard/no-wrap-up
sentence (same constraints, less dilution), fix the module docstring to
describe the headings that actually shipped, and correct the #10896 comment's
heading name (Historical Pending User Asks).
2026-06-11 13:57:13 -07:00
Teknium acb2954d82 fix(agent): freeze carveout-era SUMMARY_PREFIX for renormalization
The prompt consolidation above retires the carveout-era prefix. Without a
frozen copy in _HISTORICAL_SUMMARY_PREFIXES, summaries persisted by
pre-upgrade builds would lose detection (_is_context_summary_content) and
renormalization (_strip_summary_prefix) — the exact regression class the
tuple exists to prevent. Adds contract tests covering every frozen prefix.

Refs #41607 #38364 #42812
2026-06-11 13:57:13 -07:00
kyssta-exe 8f8cad7ec5 fix(agent): strengthen compression preamble against stale task execution (#41607) 2026-06-11 13:57:13 -07:00
konsisumer d5e2fbf244 fix(agent): frame compaction handoff sections as historical context 2026-06-11 13:57:13 -07:00
brooklyn! 484f484c25 fix(desktop): carve sidebar nav rows out of the titlebar drag region (#44453)
A WSL2 user reported the top two left-sidebar items being unclickable
while the rest of the UI works. That symptom shape matches an
-webkit-app-region:drag hit-test band eating clicks, not GPU/compositing:
the shell's titlebar drag strips (app-shell.tsx) span the top 34px and
the nav group clears them by only 6px, and drag regions win hit-testing
over DOM regardless of pointer-events. Linux WCO (Electron >=32) is the
newest implementation and has known region quirks (electron#43030).

Apply the same no-drag carve-out the codebase already uses for sticky
user bubbles (USER_BUBBLE_BASE_CLASS in thread.tsx) to the sidebar nav
buttons. Harmless on every platform: the rows were never meant to be
draggable surface.
2026-06-11 15:10:09 -05:00
teknium1 114e265737 fix(plugins): don't cache a failed discovery sweep as discovered
Root-cause hardening for the stranded-empty-registry failure behind
'No web search/extract provider configured': discover_and_load() set
_discovered=True before scanning, so a sweep that raised partway was
swallowed by callers as a warning and every later call early-returned
against an empty registry for the process lifetime. The flag now acts
only as a re-entrancy guard and is reset when the sweep raises, so the
next call retries discovery.
2026-06-11 12:56:44 -07:00
xxxigm 32a73010bb test(web): cover keyless default surviving a failed plugin sweep
Pins the invariant that _ensure_web_plugins_loaded registers the keyless
Parallel default (and the wider bundled set) even when the general plugin
discovery raises, that the direct-registration fallback honors plugins.disabled,
and that it stays a no-op on the healthy path.
2026-06-11 12:56:44 -07:00
xxxigm 93764b9303 fix(web): guarantee the keyless web default registers even if discovery doesn't
web_search/web_extract are documented to work with zero setup via the bundled
keyless Parallel free-MCP backend, but that only holds when the bundled
plugins/web/* providers are registered. The dispatch relied entirely on the
general plugin sweep to do that; when the sweep finishes without registering
them (its exception swallowed as a warning, a packaged layout where it ran
before the bundled tree was importable, or a stale empty-discovery cache), the
registry is empty and BOTH tools dead-end on "No web {search,extract} provider
configured" — despite needing no setup at all.

_ensure_web_plugins_loaded now verifies the keyless default landed after the
sweep and, if not, registers the bundled web providers directly against the
registry. Idempotent, a no-op on the healthy path (one dict lookup), and honors
an explicit plugins.disabled entry.
2026-06-11 12:56:44 -07:00
c3464ecf45 fix(discord): recover from runtime gateway task exits (#44383)
* fix(discord): recover from runtime gateway task exits

Salvaged from #39416 (AMEOBIUS) — cherry-picked only the task-exit
recovery; the original PR was 1081 commits behind with 28 unrelated
commits.

A post-ready discord.py WebSocket crash left the gateway split-brained:
producers stayed active while Discord stopped responding. After this fix
the adapter calls _set_fatal_error(retryable=True) + _notify_fatal_error()
so the existing GatewayRunner reconnect watcher replaces the dead adapter.

Also adds _wait_for_ready_or_bot_exit() so startup failures (SOCKS/proxy
errors, invalid tokens) surface fast instead of burning the full ready
timeout. Because connect() no longer waits via asyncio.wait_for on that
path, test_connect_releases_token_lock_on_timeout is updated to trigger
the timeout through the new helper (same lock-release contract).

3 tests pass (2 new runtime-failure tests + the updated timeout test);
test_discord_connect.py and test_discord_slash_commands.py green.

Co-Authored-By: ameobius <ameobius@local.host>

* fix(test): patch _wait_for_ready_or_bot_exit in timeout cancel test

connect() no longer uses asyncio.wait_for for the ready handshake, so
test_connect_timeout_cancels_bot_task was hanging for 30s in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: ameobius <ameobius@local.host>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:39:01 -04:00
ethernet e080365a7a fix(tui): new weird typeerror 2026-06-11 15:36:39 -04:00
ethernet 5e5308d34d fix(node): fix @types/node version
TODO lock to a specific node/npm version.
this is a fix for a diff between 10 and 11.
2026-06-11 15:36:39 -04:00
teknium1 08b1c44a53 fix(discord): extend bot-task cancellation to connect()'s generic exception branch
Follow-up to #44389: the generic 'except Exception' branch in connect()
had the same orphaned-task hazard as the timeout branch. Extract the
cancel-and-await logic into _cancel_bot_task() and call it from all
three sites (timeout branch, exception branch, disconnect()).

Also adds deaneeth to AUTHOR_MAP.
2026-06-11 12:09:18 -07:00
Dineth Hettiarachchi 020ef76cf1 fix(discord): cancel _bot_task on connect() timeout to prevent zombie client
When connect() times out waiting for the Discord ready event, the background
asyncio.Task running client.start() was not cancelled. discord.py's internal
reconnect loop can ignore client.close() while a WebSocket handshake is in
flight, so the orphaned task eventually completes and fires on_ready.

A later successful reconnect then leaves two live Discord clients in the same
process — each with its own on_message handler and MessageDeduplicator instance
— so every @mention creates two threads because the per-adapter dedup caches
cannot catch cross-client duplicates.

Fix: explicitly cancel and await _bot_task in two places:
1. The asyncio.TimeoutError handler inside connect() — catches the case where
   the adapter's own inner wait_for fires before the gateway's outer timeout.
2. The start of disconnect() — the load-bearing path, always reached via
   _dispose_unused_adapter regardless of which timeout fired first.

Root cause confirmed from production logs: a Jun 8 network outage caused three
consecutive connect() timeouts. The first attempt's bot_task completed its
handshake 4 minutes later ("Connected as") with no preceding watcher line,
then the watcher's real reconnect also connected 90 seconds after that. The two
clients ran continuously for 41+ hours, confirmed by the same user message
appearing as two separate inbound events in two different thread IDs 357ms apart.

Regression tests added to tests/gateway/test_discord_connect.py:
- test_connect_timeout_cancels_bot_task: simulates a connect() timeout with a
  NeverReadyBot and asserts _bot_task is None afterward
- test_disconnect_cancels_running_bot_task: injects a live zombie task, calls
  disconnect(), and asserts the task is cancelled and the attribute cleared
2026-06-11 12:09:18 -07:00
37 changed files with 4627 additions and 90 deletions
+49
View File
@@ -0,0 +1,49 @@
name: E2E CLI Tests
on:
push:
branches:
- "**"
permissions:
contents: read
jobs:
e2e-tui-test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: cd e2e && CI=true npm run test
env:
# Ensure tests don't accidentally call real APIs
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
- name: Bundle TUI traces into self-contained replay HTML
if: always()
run: node e2e/scripts/bundle-replay-html.mjs
- name: Upload TUI replay viewer
uses: actions/upload-artifact@v4
if: always()
with:
name: tui-replay-viewer
path: tui-replay-viewer/
retention-days: 7
- name: Upload raw TUI test traces
uses: actions/upload-artifact@v4
if: always()
with:
name: tui-test-traces
path: e2e/tui-traces/
retention-days: 7
+2
View File
@@ -19,6 +19,8 @@ __pycache__/
.notebooklm-playwright/
.pip-cache/
.uv-cache/
.tui-test/
tui-traces/
compose.hermes.local.yml
export*
__pycache__/model_tools.cpython-310.pyc
+56 -23
View File
@@ -7,7 +7,7 @@ protecting head and tail context.
Improvements over v2:
- Structured summary template with Resolved/Pending question tracking
- Filter-safe summarizer preamble that treats prior turns as source material
- "Remaining Work" replaces "Next Steps" to avoid reading as active instructions
- Historical (reference-only) section headings replace "Next Steps"/"Remaining Work" to avoid reading as active instructions
- Clear separator when summary merges into tail message
- Iterative summary updates (preserves info across multiple compactions)
- Token-budget tail protection instead of fixed message count
@@ -34,7 +34,50 @@ from agent.redact import redact_sensitive_text
logger = logging.getLogger(__name__)
HISTORICAL_TASK_HEADING = "## Historical Task Snapshot"
HISTORICAL_IN_PROGRESS_HEADING = "## Historical In-Progress State"
HISTORICAL_PENDING_ASKS_HEADING = "## Historical Pending User Asks"
HISTORICAL_REMAINING_WORK_HEADING = "## Historical Remaining Work"
SUMMARY_PREFIX = (
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Respond ONLY to the latest user message that appears AFTER this "
"summary — that message is the single source of truth for what to do "
"right now. "
"Topic overlap with the summary does NOT mean you should resume its "
"task: even on similar topics, the latest user message WINS. Treat ONLY "
"the latest message as the active task and discard stale items from "
f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / "
f"'{HISTORICAL_PENDING_ASKS_HEADING}' / "
f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or "
"'finish' work described there unless the latest message explicitly "
"asks for it. "
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
"topic) must immediately end any in-flight work described in the "
"summary; do not re-surface it in later turns. "
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
# stale directive it carried (e.g. "resume exactly from Active Task") survives
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
_HISTORICAL_SUMMARY_PREFIXES = (
# Carveout era (#41607/#38364/#42812): "consistent → use as background"
# licensed stale-task resumption on topic overlap.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
@@ -57,17 +100,7 @@ SUMMARY_PREFIX = (
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
# stale directive it carried (e.g. "resume exactly from Active Task") survives
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
_HISTORICAL_SUMMARY_PREFIXES = (
"described here — avoid repeating it:",
# Pre-#35344: contained the self-contradicting "resume exactly" directive.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
@@ -1155,7 +1188,7 @@ class ContextCompressor(ContextEngine):
)
reason_text = f" Summary failure reason: {reason}." if reason else ""
body = f"""## Active Task
body = f"""{HISTORICAL_TASK_HEADING}
{active_task}
## Goal
@@ -1172,7 +1205,7 @@ Recovered from a deterministic fallback because the LLM context summarizer was u
## Active State
Unknown from deterministic fallback. Inspect current repository/session state if needed.
## In Progress
{HISTORICAL_IN_PROGRESS_HEADING}
{active_task}
## Blocked
@@ -1184,13 +1217,13 @@ None recoverable from deterministic fallback.
## Resolved Questions
None recoverable from deterministic fallback.
## Pending User Asks
{HISTORICAL_PENDING_ASKS_HEADING}
{active_task}
## Relevant Files
{_bullets(relevant_files, limit=12)}
## Remaining Work
{HISTORICAL_REMAINING_WORK_HEADING}
Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims.
## Last Dropped Turns
@@ -1312,7 +1345,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
_temporal_anchoring_rule = ""
# Shared structured template (used by both paths).
_template_sections = f"""## Active Task
_template_sections = f"""{HISTORICAL_TASK_HEADING}
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
input verbatim — the exact words they used. This includes:
- Explicit task assignments ("refactor the auth module")
@@ -1359,7 +1392,7 @@ Be specific with file paths, commands, line numbers, and results.]
- Any running processes or servers
- Environment details that matter]
## In Progress
{HISTORICAL_IN_PROGRESS_HEADING}
[Work currently underway — what was being done when compaction fired]
## Blocked
@@ -1371,14 +1404,14 @@ Be specific with file paths, commands, line numbers, and results.]
## Resolved Questions
[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
## Pending User Asks
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."]
{HISTORICAL_PENDING_ASKS_HEADING}
[Questions or requests from the user that have NOT yet been answered or fulfilled. These are STALE — they were from the compacted turns. Write them here for reference only. The agent must NOT act on them unless the latest user message explicitly requests it. If none, write "None."]
## Relevant Files
[Files read, modified, or created — with brief note on each]
## Remaining Work
[What remains to be done — framed as context, not instructions]
{HISTORICAL_REMAINING_WORK_HEADING}
[What remains to be done — framed as STALE context for reference only. The agent must NOT resume this work unless the latest user message explicitly asks for it.]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.]
@@ -1753,7 +1786,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
Context compressor bug (#10896): ``_align_boundary_backward`` can pull
``cut_idx`` past a user message when it tries to keep tool_call/result
groups together. If the last user message ends up in the *compressed*
middle region the LLM summariser writes it into "Pending User Asks",
middle region the LLM summariser writes it into "Historical Pending User Asks",
but ``SUMMARY_PREFIX`` tells the next model to respond only to user
messages *after* the summary — so the task effectively disappears from
the active context, causing the agent to stall, repeat completed work,
+1 -1
View File
@@ -105,7 +105,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",
"@types/node": "^24.12.0",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.1",
+8 -1
View File
@@ -797,7 +797,14 @@ export function ChatSidebar({
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
// no-drag: these rows sit directly under the titlebar's
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
// 6px of clearance. Drag regions win hit-testing over DOM
// (pointer-events can't override), and on Linux/WSLg the
// resolved region has been observed to swallow clicks on the
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
// thread.tsx.
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
+2 -2
View File
@@ -1,4 +1,4 @@
"""Automation Blueprints — parameterized automation templates with typed slots.
"""Automation Blueprints — parameterized automation blueprints with typed slots.
A *blueprint* is a one-place definition of an automation that every surface
renders natively:
@@ -81,7 +81,7 @@ class BlueprintSlot:
@dataclass(frozen=True)
class AutomationBlueprint:
"""A parameterized automation template."""
"""A parameterized automation blueprint."""
key: str
title: str
+14
View File
@@ -0,0 +1,14 @@
{
"name": "hermes-agent-e2e",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "npm exec @microsoft/tui-test -t",
"replay": "npm exec @microsoft/tui-test show-trace"
},
"devDependencies": {
"@microsoft/tui-test": "^0.0.4",
"tui-replay": "^0.4.3"
}
}
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env node
/**
* Bundle tui-replay traces into a single self-contained HTML file.
*
* Run from the repo root after e2e tests complete:
* node e2e/scripts/bundle-replay-html.mjs
*
* Input: e2e/tui-traces/ (default @microsoft/tui-test output dir)
* Output: tui-replay-viewer/replay.html (uploaded as a GHA artifact)
*/
import { createReplayDataSource } from 'tui-replay';
import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
import { resolve, join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '../..');
// tui-replay/dist/ — resolved via ESM so package exports are honoured
const tuiReplayDist = dirname(fileURLToPath(import.meta.resolve('tui-replay')));
const tracesDir = resolve(repoRoot, 'e2e/tui-traces');
const outputDir = resolve(repoRoot, 'tui-replay-viewer');
const outputFile = join(outputDir, 'replay.html');
// ── exact strings to patch in client.js ────────────────────────────────────
const SELECTORS_IMPORT =
'import { annotationsForFrame, frameIndexAtTime, timelineItems } from "../preview/selectors.js";';
// Lines 166-172 of dist/viewer/client.js (0.4.x)
const FETCH_ORIGINAL = `async function fetchPreviewModel() {
const response = await fetch("/api/traces");
if (!response.ok) {
throw new Error(\`Unable to load traces: \${response.status}\`);
}
return (await response.json());
}`;
const FETCH_PATCHED = `async function fetchPreviewModel() {
return __INLINE_MODEL__;
}`;
// Lines 140-149 of dist/viewer/client.js (0.4.x)
const CONNECT_ORIGINAL = `function connectLiveUpdates() {
if (!("EventSource" in window)) {
startPollingLiveUpdates();
return;
}
const events = new EventSource("/api/events");
events.addEventListener("model", (event) => {
applyModelUpdate(JSON.parse(event.data));
});
}`;
const CONNECT_PATCHED = `function connectLiveUpdates() {
/* static mode: no live updates */
}`;
// ───────────────────────────────────────────────────────────────────────────
async function main() {
// Gracefully skip when traces haven't been written yet (e.g. tests skipped)
try {
await access(tracesDir);
} catch {
console.log(`tui-traces dir not found at ${tracesDir} — skipping HTML bundle.`);
process.exit(0);
}
console.log(`Loading traces from ${tracesDir}`);
const dataSource = createReplayDataSource({
inputs: [tracesDir],
projectRoot: repoRoot,
});
const model = await dataSource.load();
if (model.traces.length === 0) {
console.log('No traces found — skipping HTML bundle.');
process.exit(0);
}
console.log(`Found ${model.traces.length} trace(s).`);
// ── Load tui-replay dist assets ──────────────────────────────────────────
// renderIndexHtml is internal (not in the public index.js export) so we
// import it directly from the dist path.
const { renderIndexHtml } = await import(
pathToFileURL(join(tuiReplayDist, 'server/html.js')).href
);
const [rawClientJs, rawSelectorsJs] = await Promise.all([
readFile(join(tuiReplayDist, 'viewer/client.js'), 'utf8'),
readFile(join(tuiReplayDist, 'preview/selectors.js'), 'utf8'),
]);
// ── Patch client.js for static/embedded use ──────────────────────────────
let clientJs = rawClientJs;
// 1. Remove the ES module import (selectors will be inlined above it)
if (!clientJs.includes(SELECTORS_IMPORT)) {
throw new Error(
'Could not find selectors import in client.js — tui-replay may have updated. ' +
'Please update the SELECTORS_IMPORT constant in bundle-replay-html.mjs.'
);
}
clientJs = clientJs.replace(SELECTORS_IMPORT + '\n', '');
// 2. Replace the live fetch with a return of the inlined model
if (!clientJs.includes(FETCH_ORIGINAL)) {
throw new Error(
'Could not find fetchPreviewModel body in client.js — tui-replay may have updated. ' +
'Please update FETCH_ORIGINAL in bundle-replay-html.mjs.'
);
}
clientJs = clientJs.replace(FETCH_ORIGINAL, FETCH_PATCHED);
// 3. Disable live-reload SSE/polling (no server in static mode)
if (!clientJs.includes(CONNECT_ORIGINAL)) {
throw new Error(
'Could not find connectLiveUpdates body in client.js — tui-replay may have updated. ' +
'Please update CONNECT_ORIGINAL in bundle-replay-html.mjs.'
);
}
clientJs = clientJs.replace(CONNECT_ORIGINAL, CONNECT_PATCHED);
// Strip sourcemap comment (optional — keeps file clean in artifact viewer)
clientJs = clientJs.replace(/\n\/\/#\s*sourceMappingURL=client\.js\.map\s*$/, '');
// ── Prepare selectors for inline use ─────────────────────────────────────
// Remove `export` keyword so the functions are available in the same
// module scope as client.js (they're no longer imported — they're just
// declared above client.js in the same <script type="module"> block).
const selectorsInline = rawSelectorsJs
.replace(/^export function /gm, 'function ')
.replace(/\n\/\/#\s*sourceMappingURL=selectors\.js\.map\s*$/, '');
// ── Embed model JSON ──────────────────────────────────────────────────────
// JSON.stringify is safe inside a JS string but escape </script> sequences
// just in case trace content contains them.
const modelJsonString = JSON.stringify(model).replace(/<\/script>/gi, '<\\/script>');
// ── Assemble HTML ─────────────────────────────────────────────────────────
const htmlTemplate = renderIndexHtml();
const SCRIPT_TAG = '<script type="module" src="/assets/client.js"></script>';
if (!htmlTemplate.includes(SCRIPT_TAG)) {
throw new Error(
'Could not find the client script tag in the HTML template — ' +
'tui-replay may have updated. Please update SCRIPT_TAG in bundle-replay-html.mjs.'
);
}
const inlinedHtml = htmlTemplate.replace(
SCRIPT_TAG,
`<script type="module">
/* tui-replay selectors (inlined) */
${selectorsInline}
/* trace model (embedded at bundle time) */
const __INLINE_MODEL__ = JSON.parse(${JSON.stringify(modelJsonString)});
/* tui-replay client (patched for static mode) */
${clientJs}
</script>`
);
// ── Write output ──────────────────────────────────────────────────────────
await mkdir(outputDir, { recursive: true });
await writeFile(outputFile, inlinedHtml, 'utf8');
const sizeKb = (Buffer.byteLength(inlinedHtml, 'utf8') / 1024).toFixed(1);
console.log(`✓ Wrote ${outputFile} (${sizeKb} KB, ${model.traces.length} trace(s))`);
}
main().catch((err) => {
console.error('bundle-replay-html failed:', err.message ?? err);
process.exit(1);
});
+30
View File
@@ -0,0 +1,30 @@
// import { test, expect } from "@microsoft/tui-test";
// import {mkdtempSync, rmSync} from "fs"
// const CTRL_C = "\x03";
// test.describe("Hermes CLI basics", () => {
// const HERMES_HOME = mkdtempSync("hermes-home")
// test.use({
// env: {HERMES_HOME},
// })
// test("hermes command is available and shows version", async ({ terminal }) => {
// terminal.write("hermes --version\n");
// // Wait for the version output to appear
// await expect(terminal.getByText(/hermes/gi, { full: false })).toBeVisible({ timeout: 15000 });
// });
// test("hermes setup wizard starts interactively", async ({ terminal }) => {
// terminal.write("hermes setup\n");
// // Wait for the wizard to start (e.g., looking for "Configure Hermes Agent" or similar)
// await expect(terminal.getByText(/configure|setup|wizard|api key/gi)).toBeVisible({ timeout: 15000 });
// // Wait for the abort/exit message (KeyboardInterrupt is what python emits on ctrl+c)
// await expect(terminal.getByText(/abort|cancel|exit|terminated|keyboardinterrupt/gi)).toBeVisible({ timeout: 5000 });
// });
// test.afterAll(() => {
// rmSync(HERMES_HOME, { force: true,recursive: true})
// })
// });
+24
View File
@@ -0,0 +1,24 @@
import { test, expect, Shell } from "@microsoft/tui-test";
import {mkdtempSync, rmSync} from "fs"
if(process.env.CI === "true") {
test.describe("install hermes", () => {
const HERMES_HOME = mkdtempSync("hermes-home")
test.use({
shell: Shell.Bash,
env: {HERMES_HOME},
})
test("hermes installer works", async ({ terminal }) => {
// simulate curl | bash for installer script
terminal.write("cat $GITHUB_WORKSPACE/scripts/install.sh | bash\n");
// Wait for the version output to appear
await expect(terminal.getByText(/asdfasdfasdf/gi, { full: false })).toBeVisible({ timeout: 150000 });
});
test.afterAll(() => {
rmSync(HERMES_HOME, { force: true,recursive: true})
})
});
}
+1 -1
View File
@@ -149,7 +149,7 @@ hermes webhook subscribe pr-review \
--deliver github_comment
```
Full automation templates gallery: [hermes-agent.nousresearch.com/docs/guides/automation-templates](https://hermes-agent.nousresearch.com/docs/guides/automation-templates)
Full automation blueprints gallery: [hermes-agent.nousresearch.com/docs/reference/automation-blueprints-catalog](https://hermes-agent.nousresearch.com/docs/reference/automation-blueprints-catalog)
Documentation: [hermes-agent.nousresearch.com](https://hermes-agent.nousresearch.com)
+13
View File
@@ -1069,8 +1069,21 @@ class PluginManager:
self._plugin_skills.clear()
self._aux_tasks.clear()
self._context_engine = None
# Set the flag up front as a re-entrancy guard (a plugin's register()
# can transitively trigger discovery again), but reset it if the sweep
# raises so a failed scan is NOT cached as "discovered with an empty
# registry" — callers swallow the exception and would otherwise be
# permanently stranded on the early-return above (the "No web provider
# configured" class of failures).
self._discovered = True
try:
self._discover_and_load_inner()
except BaseException:
self._discovered = False
raise
def _discover_and_load_inner(self) -> None:
"""The actual discovery sweep — see :meth:`discover_and_load`."""
manifests: List[PluginManifest] = []
# 1. Bundled plugins (<repo>/plugins/<name>/)
+1 -1
View File
@@ -6779,7 +6779,7 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None):
# ---------------------------------------------------------------------------
# Automation Blueprints — parameterized automation templates. The dashboard renders the
# Automation Blueprints — parameterized automation blueprints. The dashboard renders the
# slot schema as a form; submitting instantiates a real cron job via the same
# create_job path. See cron/blueprint_catalog.py for the single source of truth.
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -21,7 +21,7 @@ let
# Single npm deps fetch from the workspace root lockfile.
# All workspace packages share this derivation.
npmDepsHash = "sha256-mYgKXE/FL4hnkrEvpVv+ULM/oeyIfO2AM9Ol8OrfWm0=";
npmDepsHash = "sha256-xs98fk+09BWHqq9fsjtGWD23BOVRzfFmRwnOVvv6lv8=";
npmDeps = pkgs.fetchNpmDeps {
inherit src;
+1821 -18
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -7,7 +7,8 @@
"apps/*",
"ui-tui",
"ui-tui/packages/*",
"web"
"web",
"e2e"
],
"scripts": {
"postinstall": "echo '✅ Browser tools ready. Run: python run_agent.py --help'",
+133 -2
View File
@@ -20,6 +20,7 @@ import tempfile
import threading
import time
from collections import defaultdict
from contextlib import suppress
from typing import Callable, Dict, List, Optional, Any, Tuple
logger = logging.getLogger(__name__)
@@ -68,6 +69,43 @@ from gateway.platforms.base import (
from tools.url_safety import is_safe_url
async def _wait_for_ready_or_bot_exit(
ready_event: asyncio.Event,
bot_task: asyncio.Task,
timeout: float,
) -> None:
"""Wait until Discord is ready, or surface early bot startup failure.
``discord.py`` startup errors (including SOCKS/proxy failures from
aiohttp-socks/python-socks) happen inside ``Bot.start()``. If ``connect()``
only waits on ``ready_event``, a dead background task still burns the full
ready timeout before the gateway supervisor can reconnect. Racing the ready
event against the bot task keeps failures fast and preserves the original
exception for logging/classification.
"""
ready_task = asyncio.create_task(ready_event.wait())
try:
done, _pending = await asyncio.wait(
{ready_task, bot_task},
timeout=timeout,
return_when=asyncio.FIRST_COMPLETED,
)
if not done:
raise asyncio.TimeoutError
if bot_task in done:
exc = bot_task.exception()
if exc is not None:
raise exc
if not ready_task.done():
raise RuntimeError("Discord bot task exited before ready")
await ready_task
finally:
if not ready_task.done():
ready_task.cancel()
with suppress(asyncio.CancelledError):
await ready_task
def _find_discord_windows_bundled_opus(discord_module: Any = None) -> Optional[str]:
"""Return discord.py's bundled Windows opus DLL path when present."""
if sys.platform != "win32":
@@ -622,6 +660,10 @@ class DiscordAdapter(BasePlatformAdapter):
self._typing_tasks: Dict[str, asyncio.Task] = {}
self._bot_task: Optional[asyncio.Task] = None
self._post_connect_task: Optional[asyncio.Task] = None
# True while disconnect() is intentionally closing discord.py. The
# bot task's done callback uses this to distinguish an operator/service
# shutdown from a runtime websocket crash.
self._disconnecting = False
# Dedup cache: prevents duplicate bot responses when Discord
# RESUME replays events after reconnects.
self._dedup = MessageDeduplicator()
@@ -634,6 +676,65 @@ class DiscordAdapter(BasePlatformAdapter):
# scanning channel.history() on cache miss (cold start / restart).
self._last_self_message_id: Dict[str, str] = {}
def _handle_bot_task_done(self, task: asyncio.Task) -> None:
"""Surface post-startup discord.py task exits to the gateway supervisor.
discord.py reconnects normal gateway interruptions internally. When its
top-level ``Bot.start()`` task actually exits after the adapter has been
marked running, the Discord websocket is dead while the Hermes gateway
process can remain alive. Treat that split-brain state as a retryable
fatal adapter error so ``GatewayRunner._handle_adapter_fatal_error`` can
remove this adapter and queue Discord for the existing reconnect watcher.
"""
if getattr(self, "_disconnecting", False):
# Intentional service/operator shutdown. Drain the task result so
# asyncio doesn't emit "exception was never retrieved" warnings.
with suppress(asyncio.CancelledError, Exception):
task.exception()
return
# Ignore stale callbacks from an older client if a reconnect already
# installed a newer Bot.start() task on this adapter instance.
if self._bot_task is not None and task is not self._bot_task:
with suppress(asyncio.CancelledError, Exception):
task.exception()
return
if not self._running:
# Startup failures are handled by _wait_for_ready_or_bot_exit() in
# connect(); this callback is only for post-startup split-brain.
with suppress(asyncio.CancelledError, Exception):
task.exception()
return
try:
exc = task.exception()
except asyncio.CancelledError:
return
except Exception as err: # pragma: no cover - defensive
exc = err
if exc is None:
message = "Discord gateway task exited without an exception"
else:
message = f"Discord gateway task exited: {exc}"
logger.error("[%s] %s", self.name, message, exc_info=exc if exc else False)
self._set_fatal_error("discord_gateway_task_exited", message, retryable=True)
async def _notify() -> None:
try:
await self._notify_fatal_error()
except Exception as notify_exc: # pragma: no cover - defensive logging
logger.warning(
"[%s] Failed to notify gateway supervisor about Discord task exit: %s",
self.name,
notify_exc,
exc_info=True,
)
asyncio.create_task(_notify())
async def connect(self) -> bool:
"""Connect to Discord and start receiving events."""
if not DISCORD_AVAILABLE:
@@ -900,25 +1001,55 @@ class DiscordAdapter(BasePlatformAdapter):
self._register_slash_commands()
# Start the bot in background
self._disconnecting = False
self._bot_task = asyncio.create_task(self._client.start(self.config.token))
self._bot_task.add_done_callback(self._handle_bot_task_done)
# Wait for ready
await asyncio.wait_for(self._ready_event.wait(), timeout=30)
# Wait for ready, but fail fast if discord.py's background startup
# task dies first (for example on SOCKS/proxy connect errors).
await _wait_for_ready_or_bot_exit(self._ready_event, self._bot_task, timeout=30)
self._running = True
return True
except asyncio.TimeoutError:
logger.error("[%s] Timeout waiting for connection to Discord", self.name, exc_info=True)
# Cancel the background bot task so it cannot fire on_message after
# this adapter is discarded. Without this, the task keeps running and
# a later successful reconnect leaves two active Discord clients that
# each process every message, producing duplicate threads/responses.
await self._cancel_bot_task()
self._release_platform_lock()
return False
except Exception as e: # pragma: no cover - defensive logging
logger.error("[%s] Failed to connect to Discord: %s", self.name, e, exc_info=True)
# Same zombie-client hazard as the timeout branch: the background
# client.start() task may already be running when a later setup
# step raises. Cancel it so the discarded adapter cannot connect.
await self._cancel_bot_task()
self._release_platform_lock()
return False
async def _cancel_bot_task(self) -> None:
"""Cancel and await the background client.start() task, if running."""
if self._bot_task and not self._bot_task.done():
self._bot_task.cancel()
try:
await self._bot_task
except (asyncio.CancelledError, Exception):
pass
self._bot_task = None
async def disconnect(self) -> None:
"""Disconnect from Discord."""
self._disconnecting = True
# Cancel the bot task before closing the client. If connect() timed out
# and returned False, the background client.start() task may still be
# running; calling client.close() alone is not enough to stop it because
# discord.py's reconnect loop can ignore the closed flag while a
# WebSocket handshake is in flight. Explicitly cancelling the task here
# ensures the zombie client cannot receive or dispatch any further events.
await self._cancel_bot_task()
# Clean up all active voice connections before closing the client
for guild_id in list(self._voice_clients.keys()):
try:
+1
View File
@@ -63,6 +63,7 @@ AUTHOR_MAP = {
"thomas.paquette@gmail.com": "RyTsYdUp",
"techxacm@gmail.com": "ProgramCaiCai",
"266365592+bmoore210@users.noreply.github.com": "bmoore210",
"123150002+deaneeth@users.noreply.github.com": "deaneeth",
"157839748+psionic73@users.noreply.github.com": "psionic73",
"manishbyatroy@gmail.com": "manishbyatroy",
"chilltulpa@gmail.com": "TheGardenGallery",
+8 -3
View File
@@ -3,7 +3,11 @@
import pytest
from unittest.mock import patch, MagicMock
from agent.context_compressor import ContextCompressor, SUMMARY_PREFIX
from agent.context_compressor import (
ContextCompressor,
HISTORICAL_TASK_HEADING,
SUMMARY_PREFIX,
)
@pytest.fixture()
@@ -157,7 +161,7 @@ class TestCompress:
result = c.compress(msgs)
combined = "\n".join(str(m.get("content", "")) for m in result)
assert "## Active Task" in combined
assert HISTORICAL_TASK_HEADING in combined
assert "Please fix the compression summary failure" in combined
assert "read_file" in combined
assert "agent/context_compressor.py" in combined
@@ -1213,7 +1217,8 @@ class TestCompressWithClient:
"""When the summary lands as standalone role='user' (e.g. head ends
with assistant/tool), the message body must include the explicit
'--- END OF CONTEXT SUMMARY ---' marker. Without it, weak models
read the verbatim past user request quoted in '## Active Task' as
read the verbatim past user request quoted in the historical task
snapshot as
fresh input (#11475, #14521).
"""
mock_response = MagicMock()
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import hermes_time
from agent.context_compressor import ContextCompressor
from agent.context_compressor import ContextCompressor, HISTORICAL_TASK_HEADING
def _compressor() -> ContextCompressor:
@@ -98,7 +98,7 @@ def test_clock_failure_omits_rule_but_compaction_still_runs():
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
assert "TEMPORAL ANCHORING" not in prompt
# Structured template still intact.
assert "## Active Task" in prompt
assert HISTORICAL_TASK_HEADING in prompt
def test_anchoring_rule_uses_date_from_hermes_time_now():
+18 -11
View File
@@ -1,9 +1,9 @@
"""Regression coverage for #35344: a resumed session must not let a stale
``## Active Task`` from an inherited compaction handoff hijack the reply to a
historical task snapshot from an inherited compaction handoff hijack the reply to a
new, unrelated user message.
The failure mode (real report): a lineage was compacted, producing a handoff
whose ``## Active Task`` described task A. The lineage was resumed later and
whose historical task snapshot described task A. The lineage was resumed later and
the user asked about an unrelated task B. The model answered with A because
the handoff's resume directive outranked the fresh ask.
@@ -16,14 +16,15 @@ named reverse-signal verbs. Two invariants guard the resume path specifically:
pre-fix stale handoff cannot keep its "resume exactly" directive forever.
2. The current handoff prefix contains an unambiguous "latest message wins /
discard stale Active Task" rule, so an unrelated new ask is privileged over
the inherited ``## Active Task``.
discard stale historical task" rule, so an unrelated new ask is privileged over
the inherited task snapshot.
These are content/structural assertions (no live model call) they pin the
mechanism that makes the stale task historical rather than active.
"""
from agent.context_compressor import (
HISTORICAL_TASK_HEADING,
SUMMARY_PREFIX,
LEGACY_SUMMARY_PREFIX,
ContextCompressor,
@@ -48,13 +49,17 @@ _OLD_CONFLICTING_PREFIX = (
def test_latest_message_wins_over_inherited_active_task():
"""The handoff must explicitly privilege the latest user message over a
stale ``## Active Task`` — the core #35344 contract."""
stale historical task snapshot the core #35344 contract."""
lower = SUMMARY_PREFIX.lower()
assert "latest user message" in lower
assert "## active task" in lower
assert HISTORICAL_TASK_HEADING.lower() in lower
# Conflict-resolution must be explicit, not implied.
assert "wins" in lower or "supersede" in lower
assert "discard" in lower
# The "consistent -> use as background" carveout licensed stale-task
# resumption on topic overlap (#41607, #38364) — it must stay gone.
assert "you may use the summary as background" not in lower
assert "topic overlap" in lower
def test_no_resume_exactly_directive_can_hijack():
@@ -69,7 +74,7 @@ def test_resumed_stale_handoff_gets_renormalized_to_current_prefix():
prefix when re-normalized on re-compaction so the "resume exactly"
directive cannot survive into a resumed session."""
stale_body = (
"## Active Task\n"
f"{HISTORICAL_TASK_HEADING}\n"
"User asked: 'Migrate the billing module to Stripe'\n\n"
"## Goal\nMigrate billing.\n"
)
@@ -86,13 +91,15 @@ def test_resumed_stale_handoff_gets_renormalized_to_current_prefix():
# current latest-message-wins framing.
assert "resume exactly" not in renormalized.lower()
assert renormalized.startswith(SUMMARY_PREFIX)
assert "wins" in renormalized.lower()
assert ("wins" in renormalized.lower()
or "priority" in renormalized.lower()
or "supersede" in renormalized.lower())
def test_legacy_prefix_handoff_also_renormalized():
"""The same upgrade applies to the oldest ``[CONTEXT SUMMARY]:`` handoff
format that may sit in a long-lived resumed lineage."""
legacy = f"{LEGACY_SUMMARY_PREFIX} ## Active Task\nUser asked: 'task A'"
legacy = f"{LEGACY_SUMMARY_PREFIX} {HISTORICAL_TASK_HEADING}\nUser asked: 'task A'"
renormalized = ContextCompressor._with_summary_prefix(legacy)
assert renormalized.startswith(SUMMARY_PREFIX)
assert LEGACY_SUMMARY_PREFIX not in renormalized
@@ -107,7 +114,7 @@ def test_inherited_handoff_detected_in_resumed_protected_head():
Task read as live intent)."""
messages = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": f"{SUMMARY_PREFIX}\n## Active Task\nUser asked: 'task A'"},
{"role": "user", "content": f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nUser asked: 'task A'"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "Unrelated task B: what's the capital of France?"},
]
@@ -129,7 +136,7 @@ def test_historical_prefixed_handoff_detected_and_stripped():
stale 'resume exactly' text as a fresh turn."""
messages = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": f"{_OLD_CONFLICTING_PREFIX}\n## Active Task\nUser asked: 'task A'"},
{"role": "user", "content": f"{_OLD_CONFLICTING_PREFIX}\n{HISTORICAL_TASK_HEADING}\nUser asked: 'task A'"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "Unrelated task B"},
]
+56 -2
View File
@@ -18,7 +18,13 @@ the agent repeatedly re-surfacing already-cancelled work across turns.
These tests pin the post-fix invariants so the conflict cannot regress.
"""
from agent.context_compressor import SUMMARY_PREFIX
from agent.context_compressor import (
HISTORICAL_IN_PROGRESS_HEADING,
HISTORICAL_PENDING_ASKS_HEADING,
HISTORICAL_REMAINING_WORK_HEADING,
HISTORICAL_TASK_HEADING,
SUMMARY_PREFIX,
)
def test_no_resume_exactly_directive():
@@ -30,8 +36,22 @@ def test_latest_message_wins_on_conflict():
"""The prefix must explicitly say latest user message wins on conflict."""
lower = SUMMARY_PREFIX.lower()
assert "latest user message" in lower
assert HISTORICAL_TASK_HEADING.lower() in lower
assert HISTORICAL_PENDING_ASKS_HEADING.lower() in lower
assert HISTORICAL_REMAINING_WORK_HEADING.lower() in lower
# Must have an explicit conflict-resolution rule.
assert "wins" in lower or "supersede" in lower or "discard" in lower
assert "wins" in lower or "supersede" in lower or "discard" in lower or "priority" in lower
def test_handoff_sections_are_framed_as_historical():
"""The summary headings referenced in the prefix must sound historical,
not like live instructions for the current turn."""
lower = SUMMARY_PREFIX.lower()
assert "## active task" not in lower
assert "## pending user asks" not in lower
assert "## remaining work" not in lower
assert HISTORICAL_TASK_HEADING.lower() in lower
assert HISTORICAL_IN_PROGRESS_HEADING.lower() in lower
def test_reverse_signals_called_out():
@@ -60,3 +80,37 @@ def test_memory_authority_preserved():
assert "MEMORY.md" in SUMMARY_PREFIX
assert "USER.md" in SUMMARY_PREFIX
assert "authoritative" in SUMMARY_PREFIX
def test_no_background_consistency_carveout():
"""The "consistent → use as background" carveout licensed stale-task
resumption on topic overlap (#41607, #38364, #42812). It must stay gone,
and the prefix must explicitly neutralize topic overlap."""
lower = SUMMARY_PREFIX.lower()
assert "you may use the summary as background" not in lower
assert "topic overlap" in lower
def test_replaced_prefixes_are_frozen_for_renormalization():
"""Every retired SUMMARY_PREFIX must be frozen into
_HISTORICAL_SUMMARY_PREFIXES, otherwise summaries persisted by older
builds lose detection/renormalization after an upgrade. The carveout-era
prefix is the latest retiree."""
from agent.context_compressor import (
_HISTORICAL_SUMMARY_PREFIXES,
ContextCompressor,
)
carveout_era = [
p for p in _HISTORICAL_SUMMARY_PREFIXES
if "you may use the summary as background" in p
]
assert carveout_era, "carveout-era prefix missing from frozen tuple"
# The live prefix must never be one of the frozen ones.
assert SUMMARY_PREFIX not in _HISTORICAL_SUMMARY_PREFIXES
# Detection + strip must work for every frozen prefix.
for old_prefix in _HISTORICAL_SUMMARY_PREFIXES:
content = old_prefix + "\n## Summary body"
assert ContextCompressor._is_context_summary_content(content)
stripped = ContextCompressor._strip_summary_prefix(content)
assert not stripped.startswith(old_prefix)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Tests for Automation Blueprints — the parameterized automation template system.
"""Tests for Automation Blueprints — the parameterized automation blueprint system.
Covers the core catalog/slot schema/renderers/fill (cron/blueprint_catalog.py),
the shared /blueprint command handler (hermes_cli/blueprint_cmd.py), and
+87 -3
View File
@@ -266,11 +266,12 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
),
)
async def fake_wait_for(awaitable, timeout):
awaitable.close()
async def fake_wait_for_ready(ready_event, bot_task, timeout):
raise asyncio.TimeoutError()
monkeypatch.setattr(discord_platform.asyncio, "wait_for", fake_wait_for)
monkeypatch.setattr(
discord_platform, "_wait_for_ready_or_bot_exit", fake_wait_for_ready
)
ok = await adapter.connect()
@@ -279,6 +280,89 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
assert adapter._platform_lock_identity is None
@pytest.mark.asyncio
async def test_connect_timeout_cancels_bot_task(monkeypatch):
"""Regression: connect() timeout must cancel _bot_task so the zombie
Discord client cannot fire on_message after the adapter is discarded.
Without this fix, the orphaned task eventually completes its WebSocket
handshake and a subsequent successful reconnect leaves two live clients
that each process every message, producing duplicate threads.
"""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
intents = SimpleNamespace(
message_content=False, dm_messages=False, guild_messages=False,
members=False, voice_states=False,
)
monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents)
class NeverReadyBot(FakeBot):
"""Bot whose start() never fires on_ready — simulates a slow gateway handshake."""
async def start(self, token):
await asyncio.Event().wait() # hang forever
monkeypatch.setattr(
discord_platform.commands,
"Bot",
lambda **kwargs: NeverReadyBot(
intents=kwargs["intents"],
proxy=kwargs.get("proxy"),
allowed_mentions=kwargs.get("allowed_mentions"),
),
)
async def fake_wait_for_ready(ready_event, bot_task, timeout):
raise asyncio.TimeoutError()
monkeypatch.setattr(
discord_platform, "_wait_for_ready_or_bot_exit", fake_wait_for_ready
)
ok = await adapter.connect()
assert ok is False
assert adapter._bot_task is None, (
"_bot_task must be cancelled and cleared on connect() timeout; "
"leaving it alive creates a zombie Discord client that produces duplicate threads"
)
@pytest.mark.asyncio
async def test_disconnect_cancels_running_bot_task(monkeypatch):
"""Regression: disconnect() must cancel _bot_task even when connect() timed out.
_dispose_unused_adapter calls disconnect() on adapters whose connect() returned
False. If _bot_task was still running (zombie), disconnect() must cancel it.
"""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
# Simulate a zombie bot_task that never finishes (as if discord.py is mid-handshake)
async def _forever():
await asyncio.Event().wait() # hang forever
zombie_task = asyncio.create_task(_forever())
adapter._bot_task = zombie_task
adapter._client = AsyncMock()
adapter._post_connect_task = None
adapter._voice_clients = {}
adapter._running = True
adapter._ready_event = asyncio.Event()
await adapter.disconnect()
# The task must have been cancelled (done + cancelled) and cleared from the adapter.
assert adapter._bot_task is None, "disconnect() must clear _bot_task"
assert zombie_task.done(), "disconnect() must have awaited the bot task to completion"
assert zombie_task.cancelled(), "disconnect() must cancel the zombie bot task"
@pytest.mark.asyncio
async def test_connect_does_not_wait_for_slash_sync(monkeypatch):
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
+34
View File
@@ -365,6 +365,40 @@ class TestPluginDiscovery:
}
assert len(non_bundled) == 1
def test_failed_discovery_is_not_cached(self, tmp_path, monkeypatch):
"""A sweep that raises must not cache 'discovered' with no plugins.
Regression for the stranded-empty-registry class of failures: callers
(e.g. tools.web_tools._ensure_web_plugins_loaded) swallow discovery
exceptions as warnings, so if a failed sweep flipped ``_discovered``
permanently, every later call would early-return against an empty
registry ("No web provider configured") for the process lifetime.
"""
plugins_dir = tmp_path / "hermes_test" / "plugins"
_make_plugin_dir(plugins_dir, "retry_plugin")
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
mgr = PluginManager()
def _boom(self_inner):
raise RuntimeError("sweep failed")
monkeypatch.setattr(PluginManager, "_discover_and_load_inner", _boom)
with pytest.raises(RuntimeError, match="sweep failed"):
mgr.discover_and_load()
assert mgr._discovered is False, "failed sweep was cached as discovered"
# A later call (with discovery healthy again) must do the real scan.
monkeypatch.undo()
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
mgr.discover_and_load()
assert mgr._discovered is True
non_bundled = {
n: p for n, p in mgr._plugins.items()
if p.manifest.source != "bundled"
}
assert len(non_bundled) == 1
def test_discover_skips_dir_without_manifest(self, tmp_path, monkeypatch):
"""Directories without plugin.yaml are silently skipped."""
plugins_dir = tmp_path / "hermes_test" / "plugins"
@@ -0,0 +1,59 @@
import asyncio
from unittest.mock import AsyncMock
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.discord.adapter import DiscordAdapter
@pytest.mark.asyncio
async def test_discord_bot_task_runtime_exit_notifies_gateway_for_reconnect(monkeypatch):
"""A post-ready discord.py websocket task crash must not leave the gateway split-brained.
Regression: producers stayed systemd-active while Discord stopped responding after
a runtime ClientOSError/ConnectionResetError. The adapter must mark Discord as a
retryable fatal platform error and notify the gateway supervisor so the existing
reconnect watcher can replace the dead adapter.
"""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="token"))
adapter._running = True
adapter._ready_event.set()
adapter._notify_fatal_error = AsyncMock()
async def crash():
raise ConnectionResetError("Cannot write to closing transport")
task = asyncio.create_task(crash())
await asyncio.sleep(0)
adapter._handle_bot_task_done(task)
await asyncio.sleep(0)
assert adapter.has_fatal_error is True
assert adapter.fatal_error_retryable is True
assert adapter.fatal_error_code == "discord_gateway_task_exited"
assert adapter.fatal_error_message is not None
assert "Cannot write to closing transport" in adapter.fatal_error_message
adapter._notify_fatal_error.assert_awaited_once()
@pytest.mark.asyncio
async def test_discord_bot_task_done_ignored_during_intentional_disconnect():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="token"))
adapter._running = True
adapter._ready_event.set()
adapter._disconnecting = True
adapter._notify_fatal_error = AsyncMock()
async def stop_cleanly():
return None
task = asyncio.create_task(stop_cleanly())
await asyncio.sleep(0)
adapter._handle_bot_task_done(task)
await asyncio.sleep(0)
assert adapter.has_fatal_error is False
adapter._notify_fatal_error.assert_not_awaited()
@@ -0,0 +1,100 @@
"""Regression: the keyless Parallel web default must survive a failed sweep.
``web_search`` / ``web_extract`` are documented to work out of the box with
zero setup via the bundled keyless Parallel free-MCP backend. That guarantee
only holds if the bundled ``plugins/web/*`` providers are registered in
``agent.web_search_registry``. The dispatch triggers the general plugin sweep
(:func:`hermes_cli.plugins._ensure_plugins_discovered`) to do that but the
sweep can finish without registering them (its exception swallowed as a
warning, a packaged layout where it ran before the bundled tree was
importable, or a stale empty-discovery cache). When that happened, *both*
tools dead-ended on "No web {search,extract} provider configured" even though
no setup should be needed.
These tests pin the invariant that :func:`tools.web_tools._ensure_web_plugins_loaded`
guarantees the keyless default is registered regardless of the sweep's outcome,
and that the direct-registration fallback honors an explicit ``plugins.disabled``
entry. Real imports from the bundled plugin modules no provider mocking.
"""
from __future__ import annotations
import pytest
import agent.web_search_registry as reg
import hermes_cli.plugins as plugins
from tools import web_tools
@pytest.fixture(autouse=True)
def _clean_registry():
reg._reset_for_tests()
yield
reg._reset_for_tests()
def _boom(*_a, **_k):
raise RuntimeError("discovery boom")
def test_keyless_default_registered_when_discovery_raises(monkeypatch):
"""A swallowed discovery failure must not strand the keyless default."""
monkeypatch.setattr(plugins, "_ensure_plugins_discovered", _boom)
assert reg.get_provider("parallel") is None
web_tools._ensure_web_plugins_loaded()
parallel = reg.get_provider("parallel")
assert parallel is not None, "keyless Parallel default not restored"
# It is the universal keyless default precisely because it does both.
assert parallel.supports_search()
assert parallel.supports_extract()
def test_fallback_registers_full_bundled_set(monkeypatch):
"""The fix covers the whole bundled provider class, not just parallel."""
monkeypatch.setattr(plugins, "_ensure_plugins_discovered", _boom)
web_tools._ensure_web_plugins_loaded()
names = {p.name for p in reg.list_providers()}
# Every bundled backend a user might have configured should be reachable
# again, so an explicit ``web.extract_backend: firecrawl`` etc. resolves.
for expected in ("parallel", "firecrawl", "tavily", "exa"):
assert expected in names, f"{expected} missing after fallback"
def test_fallback_honors_explicit_disable(monkeypatch):
"""A backend the user turned off via plugins.disabled stays off."""
monkeypatch.setattr(plugins, "_get_disabled_plugins", lambda: {"web-parallel"})
web_tools._register_bundled_web_providers_directly()
names = {p.name for p in reg.list_providers()}
assert "parallel" not in names, "explicit disable was ignored"
# Other bundled backends are unaffected by the parallel disable.
assert "tavily" in names
def test_fallback_is_noop_when_discovery_already_registered(monkeypatch):
"""Healthy path: don't pay for the direct sweep when parallel is present."""
# Pretend the general sweep already registered the keyless default.
import importlib
class _Ctx:
def register_web_search_provider(self, provider):
reg.register_provider(provider)
importlib.import_module("plugins.web.parallel").register(_Ctx())
monkeypatch.setattr(plugins, "_ensure_plugins_discovered", lambda *a, **k: None)
calls = {"n": 0}
real = web_tools._register_bundled_web_providers_directly
def _spy():
calls["n"] += 1
real()
monkeypatch.setattr(web_tools, "_register_bundled_web_providers_directly", _spy)
web_tools._ensure_web_plugins_loaded()
assert calls["n"] == 0, "direct-registration ran on the healthy path"
+92
View File
@@ -810,6 +810,17 @@ def _ensure_web_plugins_loaded() -> None:
Mirrors :func:`tools.browser_tool._ensure_browser_plugins_loaded` exactly:
the underlying discovery call is idempotent and cheap on subsequent
invocations.
Triggering discovery is necessary but not *sufficient*: the sweep can
finish without registering the bundled web providers (its exception
swallowed below as a warning, a packaged layout where discovery ran before
the bundled tree was importable, or a stale empty-discovery cache). When
that happens the registry is empty and *both* web_search and web_extract
dead-end on "No web {search,extract} provider configured" even though the
keyless Parallel default is supposed to work with zero setup. So after
discovery we verify the keyless default landed and, if not, register the
bundled providers directly (see
:func:`_register_bundled_web_providers_directly`).
"""
try:
from hermes_cli.plugins import _ensure_plugins_discovered
@@ -822,6 +833,87 @@ def _ensure_web_plugins_loaded() -> None:
# clue in normal logs about the real cause.
logger.warning("Web plugin discovery failed (non-fatal): %s", exc)
# Belt-and-suspenders: guarantee the keyless Parallel default (the
# documented zero-setup backend for both web_search and web_extract) is
# actually registered. The lookup is a cheap dict hit on the healthy path
# (discovery already registered it → no-op); only an empty registry pays
# for the direct-registration sweep.
try:
from agent.web_search_registry import get_provider
if get_provider("parallel") is None:
_register_bundled_web_providers_directly()
except Exception as exc: # noqa: BLE001
logger.debug("Bundled web provider fallback check failed: %s", exc)
def _register_bundled_web_providers_directly() -> None:
"""Register the repo's bundled web providers without the plugin manager.
The normal path is the general plugin sweep
(:func:`hermes_cli.plugins._ensure_plugins_discovered`), which auto-loads
every ``plugins/web/<name>`` backend (they are ``kind: backend``). This
fallback exists for the runtimes where that sweep does not leave the web
registry populated so the keyless Parallel default (and any bundled
backend the user explicitly configured) keeps working instead of
surfacing a misleading "No web provider configured" error.
Imports each bundled ``plugins/web/<name>`` package and calls its
``register()`` directly against :mod:`agent.web_search_registry`. Idempotent
(re-register overwrites) and honors an explicit ``plugins.disabled`` entry
so a backend the user turned off stays off.
"""
try:
from hermes_cli.plugins import (
_get_disabled_plugins,
get_bundled_plugins_dir,
)
except Exception as exc: # noqa: BLE001
logger.debug("Bundled web provider fallback unavailable: %s", exc)
return
web_dir = get_bundled_plugins_dir() / "web"
if not web_dir.is_dir():
return
disabled = _get_disabled_plugins()
from agent.web_search_provider import WebSearchProvider
from agent.web_search_registry import register_provider
class _DirectRegistrationCtx:
"""Minimal plugin ctx exposing only web-provider registration."""
def register_web_search_provider(self, provider) -> None:
if isinstance(provider, WebSearchProvider):
register_provider(provider)
ctx = _DirectRegistrationCtx()
import importlib
for child in sorted(web_dir.iterdir()):
if not child.is_dir():
continue
if not (child / "plugin.yaml").exists() and not (child / "plugin.yml").exists():
continue
# Respect an explicit disable — match discover_and_load's key/name
# check (key ``web/<dir>``; manifest name ``web-<dir-with-dashes>``).
if (
f"web/{child.name}" in disabled
or f"web-{child.name.replace('_', '-')}" in disabled
):
continue
try:
module = importlib.import_module(f"plugins.web.{child.name}")
register_fn = getattr(module, "register", None)
if callable(register_fn):
register_fn(ctx)
except Exception as exc: # noqa: BLE001
logger.debug(
"Direct registration of bundled web provider '%s' failed: %s",
child.name, exc,
)
def web_search_tool(query: str, limit: int = 5) -> str:
"""
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
},
"devDependencies": {
"@eslint/js": "^9",
"@types/node": "^24.12.0",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@typescript-eslint/eslint-plugin": "^8",
"@typescript-eslint/parser": "^8",
+1 -1
View File
@@ -80,7 +80,7 @@ const asWireText = (raw: unknown): string | null => {
}
if (raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)) {
return _wireDecoder.decode(raw as ArrayBufferLike)
return _wireDecoder.decode(raw as any as ArrayBuffer)
}
return null
+1 -1
View File
@@ -36,7 +36,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/node": "^24.13.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+1 -1
View File
@@ -497,7 +497,7 @@ export const api = {
deleteCronJob: (id: string, profile = "default") =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }),
// Automation Blueprints — parameterized automation templates
// Automation Blueprints — parameterized automation blueprints
getAutomationBlueprints: () =>
fetchJSON<{ blueprints: AutomationBlueprint[] }>("/api/cron/blueprints"),
instantiateAutomationBlueprint: (
@@ -1,14 +1,16 @@
---
sidebar_position: 15
title: "Automation Templates"
description: "Ready-to-use automation recipes — scheduled tasks, GitHub event triggers, API webhooks, and multi-skill workflows"
title: "Automation Blueprints"
description: "Ready-to-use automation blueprints — scheduled tasks, GitHub event triggers, API webhooks, and multi-skill workflows"
---
# Automation Templates
# Automation Blueprints
Copy-paste recipes for common automation patterns. Each template uses Hermes's built-in [cron scheduler](/user-guide/features/cron) for time-based triggers and [webhook platform](/user-guide/messaging/webhooks) for event-driven triggers.
Copy-paste blueprints for common automation patterns. Each blueprint uses Hermes's built-in [cron scheduler](/user-guide/features/cron) for time-based triggers and [webhook platform](/user-guide/messaging/webhooks) for event-driven triggers.
Every template works with **any model** — not locked to a single provider.
Every blueprint works with **any model** — not locked to a single provider.
For parameterized blueprints with forms instead of cron syntax, see the [Automation Blueprints Catalog](/reference/automation-blueprints-catalog).
:::tip Three Trigger Types
| Trigger | How | Tool |
@@ -1,14 +1,14 @@
---
sidebar_position: 7
title: "Automation Blueprints Catalog"
description: "Ready-to-run automation templates — set one up from the dashboard, CLI, TUI, any messenger, or the desktop app."
description: "Ready-to-run automation blueprints — set one up from the dashboard, CLI, TUI, any messenger, or the desktop app."
---
import AutomationBlueprintsCatalog from '@site/src/components/AutomationBlueprintsCatalog';
# Automation Blueprints
Automation Blueprints are ready-to-run automation templates. Pick one, fill in a couple
Automation Blueprints are ready-to-run automations. Pick one, fill in a couple
of fields, and Hermes schedules it as a cron job — no cron syntax required.
Every blueprint works from **every surface**:
@@ -1,14 +1,16 @@
---
sidebar_position: 15
title: "自动化模板"
description: "开箱即用的自动化配方——定时任务、GitHub 事件触发、API webhook 及多技能工作流"
title: "自动化蓝图"
description: "开箱即用的自动化蓝图——定时任务、GitHub 事件触发、API webhook 及多技能工作流"
---
# 自动化模板
# 自动化蓝图
常见自动化模式的复制粘贴配方。每个模板使用 Hermes 内置的 [cron 调度器](/user-guide/features/cron) 实现基于时间的触发,使用 [webhook 平台](/user-guide/messaging/webhooks) 实现事件驱动触发。
常见自动化模式的复制粘贴蓝图。每个蓝图使用 Hermes 内置的 [cron 调度器](/user-guide/features/cron) 实现基于时间的触发,使用 [webhook 平台](/user-guide/messaging/webhooks) 实现事件驱动触发。
所有模板适用于**任意模型**——不绑定单一提供商。
所有蓝图适用于**任意模型**——不绑定单一提供商。
如需带表单的参数化蓝图(无需手写 cron 语法),请参阅[自动化蓝图目录](/reference/automation-blueprints-catalog)。
:::tip 三种触发类型
| 触发方式 | 方式 | 工具 |
+1 -1
View File
@@ -684,7 +684,7 @@ const sidebars: SidebarsConfig = {
'guides/build-a-hermes-plugin',
'guides/automate-with-cron',
'guides/cron-script-only',
'guides/automation-templates',
'guides/automation-blueprints',
'guides/cron-troubleshooting',
'guides/work-with-skills',
'guides/delegation-patterns',