Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4312a9cc4e | ||
|
|
021ed69141 | ||
|
|
6c752ca3a5 | ||
|
|
acb2954d82 | ||
|
|
8f8cad7ec5 | ||
|
|
d5e2fbf244 | ||
|
|
484f484c25 | ||
|
|
114e265737 | ||
|
|
32a73010bb | ||
|
|
93764b9303 | ||
|
|
c3464ecf45 | ||
|
|
e080365a7a | ||
|
|
5e5308d34d | ||
|
|
08b1c44a53 | ||
|
|
020ef76cf1 |
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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})
|
||||
// })
|
||||
// });
|
||||
@@ -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})
|
||||
})
|
||||
});
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>/)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Generated
+1821
-18
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -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'",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"},
|
||||
]
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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: (
|
||||
|
||||
+7
-5
@@ -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**:
|
||||
|
||||
+7
-5
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user