diff --git a/packages/adapters/hermes/README.md b/packages/adapters/hermes/README.md index fb9d267e7b..1ee190c753 100644 --- a/packages/adapters/hermes/README.md +++ b/packages/adapters/hermes/README.md @@ -312,6 +312,43 @@ up where the last one left off, maintaining conversation context, memories, and tool state across heartbeats. The `sessionCodec` validates and migrates session state between runs. +### Managed instructions and resumed turns + +The local adapter reads `instructionsFilePath` on every run. It sends that +bundle, its relative-reference base directory, and the standard Paperclip API +guidance through Hermes's native `HERMES_EPHEMERAL_SYSTEM_PROMPT` overlay. +Hermes applies this overlay at each model request, including after context +compression. It does not append the overlay to saved user turns. This requires +a Hermes CLI that supports the [documented environment hook](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/reference/environment-variables.md). + +Fresh sessions, resumed sessions, and session resets all receive the current +bundle. Edits take effect on the next run; removing the bundle removes it from +the next overlay. The server's session compatibility and conversation generation +checks still control session reuse. A missing or failed resume remains a failed +run and requires an explicit session reset; it does not automatically restart +work. The next fresh run receives the complete current instruction overlay. + +The `-q` user turn contains current runtime identity, task/wake context, handoff +content, and the rendered custom `promptTemplate`, if configured. Custom templates +run on every normal wake. `quiet` changes CLI display only. Disabling persistence +uses full fresh-session context even if old session parameters are supplied. + +An explicit `HERMES_EPHEMERAL_SYSTEM_PROMPT` in `env` is preserved before the +Paperclip overlay. Hermes gives this environment hook precedence over its profile's +configured personality/system prompt. Put required additional profile guidance in +that explicit environment value or in the managed bundle. Paperclip does not write +Hermes profile files or modify the parent process environment. Existing copies in +old user turns remain in history until Hermes compresses them or the session resets. + +Run the credential-free capture-process regression tests with: + +```sh +pnpm --filter @paperclipai/hermes-paperclip-adapter test -- src/server/execute.instructions.test.ts +``` + +These tests verify actual child-process arguments and environment, not provider +billing, model behavior, or measured token savings. + ### Skills Integration The adapter scans two skill sources and merges them: diff --git a/packages/adapters/hermes/src/index.ts b/packages/adapters/hermes/src/index.ts index 1c1b9d3398..4ff7488b99 100644 --- a/packages/adapters/hermes/src/index.ts +++ b/packages/adapters/hermes/src/index.ts @@ -114,6 +114,14 @@ tools, persistent memory, session persistence, skills, and MCP support. | env | object | {} | Extra environment variables | | promptTemplate | string | (default) | Custom prompt template with {{variable}} placeholders | +Managed \`instructionsFilePath\` bundles and standard Paperclip API guidance use +Hermes's native \`HERMES_EPHEMERAL_SYSTEM_PROMPT\` overlay on every run, including +resume and reset. They are applied at request time and are not appended to user +history. The query keeps current runtime identity, wake/task content, and custom +templates. Use a Hermes CLI that supports this environment hook. An explicit +overlay in \`env\` is preserved; the hook takes precedence over Hermes's configured +profile personality/system prompt. + ## Hermes-Originated Paperclip Tasks This adapter package also ships a Hermes-facing Paperclip task bridge skill: diff --git a/packages/adapters/hermes/src/server/execute.instructions.test.ts b/packages/adapters/hermes/src/server/execute.instructions.test.ts new file mode 100644 index 0000000000..5423e5568d --- /dev/null +++ b/packages/adapters/hermes/src/server/execute.instructions.test.ts @@ -0,0 +1,210 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; + +import { execute } from "./execute.js"; +import { sessionCodec } from "./index.js"; + +const SESSION_ID = "20261006_200000_abcdef"; +const INSTRUCTIONS = "Synthetic governance marker: request approval before governed actions."; + +interface Invocation { + args: string[]; + systemPrompt: string | null; + runId: string; +} + +describe("Hermes managed instruction delivery (real capture process)", () => { + let dir: string; + let command: string; + let instructionsFilePath: string; + let capturePath: string; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hermes-instructions-")); + command = path.join(dir, "hermes.cjs"); + instructionsFilePath = path.join(dir, "instructions.md"); + capturePath = path.join(dir, "invocations.jsonl"); + await fs.writeFile(instructionsFilePath, INSTRUCTIONS); + await fs.writeFile(command, `#!${process.execPath} +const fs = require("node:fs"); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.CAPTURE_PATH, JSON.stringify({ + args, + systemPrompt: process.env.HERMES_EPHEMERAL_SYSTEM_PROMPT ?? null, + runId: process.env.PAPERCLIP_RUN_ID, +}) + "\\n"); +if (process.env.FIXTURE_STDOUT) console.log(process.env.FIXTURE_STDOUT); +if (args.includes("missing-session")) { + console.error("\\u001b[1;31mSession missing-session not found.\\u001b[0m"); + process.exit(1); +} +if (process.env.FIXTURE_PROVIDER_ERROR) { + console.error(process.env.FIXTURE_PROVIDER_ERROR); + process.exit(1); +} +console.log("Fixture completed\\nsession_id: ${SESSION_ID}"); +`, { mode: 0o700 }); + }); + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + function context(overrides: Partial = {}): AdapterExecutionContext { + return { + runId: "run-fresh", + agent: { + id: "agent-synthetic", companyId: "company-synthetic", name: "Hermes fixture", + adapterType: "hermes_local", adapterConfig: {}, + }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: "task-synthetic" }, + config: { + hermesCommand: command, provider: "openrouter", model: "fixture-model", cwd: dir, + instructionsFilePath, env: { CAPTURE_PATH: capturePath }, timeoutSec: 10, graceSec: 1, + }, + context: { + issueId: "task-synthetic", + paperclipTaskMarkdownAssignment: "Complete current synthetic task.", + paperclipTaskMarkdownCompact: "Current synthetic task.", + paperclipWake: { + reason: "issue_commented", + issue: { id: "task-synthetic", title: "Synthetic task", status: "in_progress" }, + comments: [{ id: "comment-synthetic", body: "Fresh human direction." }], + commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, + fallbackFetchNeeded: false, + }, + }, + onLog: async () => {}, + ...overrides, + }; + } + + async function invocations(): Promise { + return (await fs.readFile(capturePath, "utf8")).trim().split("\n").map((line) => JSON.parse(line)); + } + + function query(invocation: Invocation): string { + return invocation.args[invocation.args.indexOf("-q") + 1]; + } + + it.each([false, true])("keeps instructions out of fresh and resumed user turns (conversation=%s)", async (conversationMode) => { + const fresh = context(); + fresh.context.conversationMode = conversationMode; + const first = await execute(fresh); + const params = sessionCodec.deserialize(sessionCodec.serialize(first.sessionParams ?? null)); + expect(params?.sessionId).toBe(SESSION_ID); + await execute({ ...fresh, runId: "run-resumed", runtime: { ...fresh.runtime, sessionParams: params } }); + + const [initial, resumed] = await invocations(); + expect(initial.args).not.toContain("--resume"); + expect(resumed.args.slice(resumed.args.indexOf("--resume"))).toEqual(["--resume", SESSION_ID]); + for (const invocation of [initial, resumed]) { + expect(invocation.systemPrompt).toContain(INSTRUCTIONS); + expect(invocation.systemPrompt).toContain(`Resolve any relative file references from ${dir}/`); + expect(query(invocation)).not.toContain(INSTRUCTIONS); + expect(query(invocation)).not.toContain("Safe multiline update pattern:"); + expect(query(invocation)).toContain("Fresh human direction."); + expect(query(invocation)).toContain(invocation.runId); + expect(query(invocation)).toContain("company-synthetic"); + expect(invocation.systemPrompt).toContain("$PAPERCLIP_API_KEY"); + expect(invocation.systemPrompt).toContain('-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"'); + expect(invocation.systemPrompt).toContain("body=$(cat <<'MD'"); + expect(invocation.systemPrompt).toContain("--data-binary @-"); + expect(invocation.systemPrompt).toContain('$api/issues/$PAPERCLIP_TASK_ID'); + expect(invocation.systemPrompt).not.toContain(invocation.runId); + } + }); + + it("reapplies current instructions after edits and a session reset", async () => { + const ctx = context(); + await execute(ctx); + await fs.writeFile(instructionsFilePath, "Updated governance marker."); + await execute({ ...ctx, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } }); + await execute({ ...ctx, runId: "run-reset" }); + const [, edited, reset] = await invocations(); + for (const invocation of [edited, reset]) { + expect(invocation.systemPrompt).toContain("Updated governance marker."); + expect(invocation.systemPrompt).not.toContain(INSTRUCTIONS); + expect(query(invocation)).not.toContain("Updated governance marker."); + } + expect(reset.args).not.toContain("--resume"); + }); + + it("uses fresh wake context when persistence is disabled despite stored session params", async () => { + const ctx = context(); + const result = await execute({ ...ctx, config: { ...ctx.config, persistSession: false }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } }); + const [invocation] = await invocations(); + expect(invocation.args).not.toContain("--resume"); + expect(query(invocation)).toContain("## Paperclip Wake Payload"); + expect(query(invocation)).not.toContain("## Paperclip Resume Delta"); + expect(invocation.systemPrompt).toContain(INSTRUCTIONS); + expect(result.sessionParams).toBeUndefined(); + }); + + it("keeps custom templates and quiet mode dynamic while preserving a configured system overlay", async () => { + const ctx = context(); + await execute({ ...ctx, config: { + ...ctx.config, quiet: true, promptTemplate: "Current custom direction for {{runId}}.", + env: { CAPTURE_PATH: capturePath, HERMES_EPHEMERAL_SYSTEM_PROMPT: "User configured system guidance." }, + }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } }); + const [invocation] = await invocations(); + expect(invocation.args).toContain("-Q"); + expect(query(invocation)).toContain("Current custom direction for run-fresh."); + expect(query(invocation)).not.toContain(INSTRUCTIONS); + expect(invocation.systemPrompt).toContain("User configured system guidance."); + expect(invocation.systemPrompt).toContain(INSTRUCTIONS); + }); + + it.each([false, true])("keeps an empty wake runnable with current identity (conversation=%s)", async (conversationMode) => { + await execute(context({ context: { conversationMode } })); + const [invocation] = await invocations(); + expect(query(invocation)).toContain("run-fresh"); + expect(query(invocation)).not.toContain(INSTRUCTIONS); + expect(invocation.systemPrompt).toContain(INSTRUCTIONS); + }); + + it("surfaces a missing session without automatically restarting work", async () => { + const ctx = context(); + const result = await execute({ ...ctx, runtime: { ...ctx.runtime, sessionParams: { sessionId: "missing-session" } } }); + const calls = await invocations(); + expect(calls).toHaveLength(1); + expect(calls[0].args).toContain("--resume"); + expect(calls[0].systemPrompt).toContain(INSTRUCTIONS); + expect(result.exitCode).toBe(1); + expect(result.sessionParams).toBeUndefined(); + }); + + it("does not retry provider failures as a missing session", async () => { + const ctx = context(); + const result = await execute({ ...ctx, config: { ...ctx.config, env: { CAPTURE_PATH: capturePath, FIXTURE_PROVIDER_ERROR: "Error: provider unavailable" } }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } }); + expect(await invocations()).toHaveLength(1); + expect(result.exitCode).toBe(1); + expect(result.errorMessage).toBe("Error: provider unavailable"); + }); + + it("preserves failed work even when its output contains a missing-session diagnostic", async () => { + const ctx = context(); + const result = await execute({ ...ctx, config: { ...ctx.config, env: { + CAPTURE_PATH: capturePath, + FIXTURE_STDOUT: `Already updated the task.\nSession ${SESSION_ID} not found.`, + FIXTURE_PROVIDER_ERROR: "Error: provider unavailable after tool execution", + } }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } }); + expect(await invocations()).toHaveLength(1); + expect(result.exitCode).toBe(1); + expect(result.summary).toContain("Already updated the task."); + expect(result.errorMessage).toBe("Error: provider unavailable after tool execution"); + }); + + it("removes an obsolete instruction overlay when the bundle is removed", async () => { + const ctx = context(); + await execute(ctx); + await execute({ ...ctx, config: { ...ctx.config, instructionsFilePath: undefined }, runtime: { ...ctx.runtime, sessionParams: { sessionId: SESSION_ID } } }); + const [, resumed] = await invocations(); + expect(resumed.systemPrompt).not.toContain(INSTRUCTIONS); + expect(resumed.systemPrompt).toContain("Paperclip API guidance:"); + expect(query(resumed)).toContain("Fresh human direction."); + }); +}); diff --git a/packages/adapters/hermes/src/server/execute.ts b/packages/adapters/hermes/src/server/execute.ts index 828a3e2e00..98a5d85846 100644 --- a/packages/adapters/hermes/src/server/execute.ts +++ b/packages/adapters/hermes/src/server/execute.ts @@ -34,7 +34,6 @@ import { renderTemplate, ensureAbsoluteDirectory, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, - DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE, joinPromptSections, selectPaperclipPromptSections, stringifyPaperclipWakePayload, @@ -82,7 +81,7 @@ export function resolveHermesCommand(config: Record): string { // Wake-up prompt builder // --------------------------------------------------------------------------- -const HERMES_DEFAULT_PROMPT_TEMPLATE = [ +const HERMES_RUNTIME_IDENTITY_TEMPLATE = [ 'You are "{{agent.name}}", an AI agent employee in a Paperclip-managed company.', "", "Paperclip runtime identity:", @@ -90,7 +89,11 @@ const HERMES_DEFAULT_PROMPT_TEMPLATE = [ "- Company ID: {{agent.companyId}}", "- Run ID: {{run.id}}", "- API base: {{paperclipApiUrl}}", - "", +].join("\n"); + +// Hermes applies this overlay at each API call, including after compaction, +// without storing it in conversation history. Keep run/task deltas in -q. +const HERMES_SYSTEM_PROMPT_TEMPLATE = [ "Paperclip API guidance:", "- Use `curl` from the terminal for Paperclip API calls; browser/web extraction tools may not reach localhost.", "- Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`; do not hard-code local ports or copy secrets into comments.", @@ -112,7 +115,7 @@ const HERMES_DEFAULT_PROMPT_TEMPLATE = [ "MD", ")", "jq -n --arg status done --arg comment \"$body\" '{status:$status, comment:$comment}' | \\", - " curl -sS -X PATCH \"$api/issues/{{context.issueId}}\" \\", + " curl -sS -X PATCH \"$api/issues/$PAPERCLIP_TASK_ID\" \\", " -H \"Authorization: Bearer $PAPERCLIP_API_KEY\" \\", " -H \"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\" \\", " -H \"Content-Type: application/json\" \\", @@ -141,9 +144,7 @@ export function buildPrompt( options: { resumedSession?: boolean } = {}, ): string { const context = (ctx as any).context || {}; - const template = cfgString(config.promptTemplate) || (context.conversationMode === true - ? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE - : HERMES_DEFAULT_PROMPT_TEMPLATE); + const template = cfgString(config.promptTemplate); const taskId = cfgString(context.taskId) || cfgString(context.issueId) || cfgString(ctx.config?.taskId); const taskTitle = cfgString(context.taskTitle) || cfgString(ctx.config?.taskTitle) || ""; const taskBody = cfgString(context.taskBody) || cfgString(ctx.config?.taskBody) || ""; @@ -200,10 +201,11 @@ export function buildPrompt( paperclipRunIdEnv: "PAPERCLIP_RUN_ID", }; - const rendered = isPaperclipRecoveryWakePayload(context.paperclipWake) - ? "" - : renderTemplate(renderConditionalSections(template, vars), vars); + const rendered = template && !isPaperclipRecoveryWakePayload(context.paperclipWake) + ? renderTemplate(renderConditionalSections(template, vars), vars) + : ""; return joinPromptSections([ + renderTemplate(HERMES_RUNTIME_IDENTITY_TEMPLATE, vars), wakePrompt, sessionHandoffMarkdown, taskContextMarkdown, @@ -404,7 +406,7 @@ export async function execute( // ── Load agent instructions file (Paperclip instruction bundles) ────── // Paperclip can materialize managed instructions into instructionsFilePath; - // when present, inject that bundle into the Hermes prompt. + // when present, reapply that bundle through Hermes's native system overlay. const instructionsFilePath = cfgString(config.instructionsFilePath); let agentInstructions = ""; if (instructionsFilePath) { @@ -430,10 +432,8 @@ export async function execute( } // ── Build prompt ─────────────────────────────────────────────────────── - let prompt = buildPrompt(ctx, config, { resumedSession: Boolean(prevSessionId) }); - if (agentInstructions) { - prompt = agentInstructions + "\n\n---\n\n" + prompt; - } + const sessionId = persistSession ? prevSessionId : undefined; + const prompt = buildPrompt(ctx, config, { resumedSession: Boolean(sessionId) }); // ── Build command args ───────────────────────────────────────────────── // Use -Q (quiet) to get clean output: just response + session_id line @@ -474,13 +474,9 @@ export async function execute( // system is designed for human-attended interactive sessions. args.push("--yolo"); - if (persistSession && prevSessionId) { - args.push("--resume", prevSessionId); - } - - if (extraArgs?.length) { - args.push(...extraArgs); - } + // Failed resumes remain failures. Do not infer a safe restart from CLI text. + if (sessionId) args.push("--resume", sessionId); + if (extraArgs?.length) args.push(...extraArgs); // ── Build environment ────────────────────────────────────────────────── const userEnv = config.env as Record | undefined; @@ -491,6 +487,17 @@ export async function execute( ...buildRuntimeToolsEnv(ctx.runtimeTools), }; + // Scope the overlay to this child process. Read the current bundle on every + // invocation; never infer its availability from a persisted session ID. + // Preserve an operator's explicit overlay before Paperclip's instructions. + env.HERMES_EPHEMERAL_SYSTEM_PROMPT = joinPromptSections([ + env.HERMES_EPHEMERAL_SYSTEM_PROMPT, + agentInstructions, + renderTemplate(HERMES_SYSTEM_PROMPT_TEMPLATE, { + agent: ctx.agent, + }), + ]); + if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId; // PAPERCLIP_API_KEY is never accepted from config — the harness-minted run @@ -527,10 +534,10 @@ export async function execute( "stdout", `[hermes] Starting Hermes Agent (model=${model}, provider=${resolvedProvider} [${resolvedFrom}], timeout=${timeoutSec}s${maxTurns ? `, max_turns=${maxTurns}` : ""})\n`, ); - if (prevSessionId) { + if (sessionId) { await ctx.onLog( "stdout", - `[hermes] Resuming session: ${prevSessionId}\n`, + `[hermes] Resuming session: ${sessionId}\n`, ); } diff --git a/packages/adapters/hermes/src/server/prompt-rendering.test.ts b/packages/adapters/hermes/src/server/prompt-rendering.test.ts index b47dca0361..dc6ba50311 100644 --- a/packages/adapters/hermes/src/server/prompt-rendering.test.ts +++ b/packages/adapters/hermes/src/server/prompt-rendering.test.ts @@ -234,19 +234,16 @@ test("keeps authoritative parent and ancestor context from task markdown", () => expect(prompt).not.toContain("check the issue body or comments for references"); }); -test("renders safe Paperclip API examples from environment variables with multiline update preservation", () => { +test("keeps current runtime identity in the user turn without repeating static API examples", () => { const prompt = buildPrompt(baseContext(), { paperclipApiUrl: "http://paperclip.local/api", }); - expect(prompt).toContain("Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`"); - expect(prompt).toContain("Displayed command logs may redact secrets"); - expect(prompt).toContain('-H "Authorization: Bearer $PAPERCLIP_API_KEY"'); - expect(prompt).toContain('-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"'); - expect(prompt).toContain("body=$(cat <<'MD'"); - expect(prompt).toContain("jq -n --arg status done --arg comment \"$body\""); - expect(prompt).toContain("--data-binary @-"); - expect(prompt).not.toContain("Authorization: Bearer <"); + expect(prompt).toContain("- Agent ID: agent-1"); + expect(prompt).toContain("- Company ID: company-1"); + expect(prompt).toContain("- Run ID: run-1"); + expect(prompt).toContain("- API base: http://paperclip.local/api"); + expect(prompt).not.toContain("Safe multiline update pattern:"); }); test("preserves custom prompt templates while exposing runtime and wake variables", () => { @@ -276,7 +273,7 @@ test("preserves custom prompt templates while exposing runtime and wake variable expect(prompt).toContain('"reason":"issue_assigned"'); expect(prompt).toContain("## Paperclip Wake Payload"); expect(prompt).toContain("Issue description:\n```text\nUse the wake payload as runtime authority.\n```"); - expect(prompt).not.toContain("Paperclip runtime identity:"); + expect(prompt).toContain("Paperclip runtime identity:"); }); test("keeps historical task markdown available to custom templates while automatic context uses assignment markdown", () => { diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index b275680550..aec054d48c 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -31,6 +31,7 @@ const nonServerProjects = [ "@paperclipai/adapter-cursor-local", "@paperclipai/adapter-gemini-local", "@paperclipai/adapter-grok-local", + "@paperclipai/hermes-paperclip-adapter", "@paperclipai/adapter-kimi-local", "@paperclipai/adapter-openclaw-gateway", "@paperclipai/adapter-opencode-local", diff --git a/vitest.config.ts b/vitest.config.ts index cceb1767e2..d2d9e52e12 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ "packages/adapters/cursor-local", "packages/adapters/gemini-local", "packages/adapters/grok-local", + "packages/adapters/hermes", "packages/adapters/kimi-local", "packages/adapters/openclaw-gateway", "packages/adapters/opencode-local",