fix(hermes): keep managed instructions out of resumed user turns (#15439)

Deliver managed instructions through Hermes's native system overlay while keeping current wake and runtime identity in user turns. Add fresh/resumed regression coverage and include Hermes in the default CI test roster.

Fixes #15385

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-07 07:07:32 -05:00
1 parent 3a726e676f
commit abbd88007f
7 files changed
+295 -34

No files matched your search

+37
View File
@@ -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:
+8
View File
@@ -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:
@@ -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> = {}): 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<Invocation[]> {
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.");
});
});
+31 -24
View File
@@ -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, unknown>): 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<string, string> | 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`,
);
}
@@ -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", () => {
+1
View File
@@ -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",
+1
View File
@@ -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",