From 8032c1364bb40b0e2ec13c3a94b414ab8f36f9f7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 23 Jun 2026 17:25:53 +0000 Subject: [PATCH] Optimize Hermes wake context prompt --- .../hermes-local/src/server/execute.ts | 187 +++++++++--------- .../src/server/prompt-rendering.test.ts | 128 ++++++++++++ server/src/services/heartbeat.ts | 25 +++ 3 files changed, 243 insertions(+), 97 deletions(-) create mode 100644 packages/adapters/hermes-local/src/server/prompt-rendering.test.ts diff --git a/packages/adapters/hermes-local/src/server/execute.ts b/packages/adapters/hermes-local/src/server/execute.ts index 50dda178aa7..614ae83481e 100644 --- a/packages/adapters/hermes-local/src/server/execute.ts +++ b/packages/adapters/hermes-local/src/server/execute.ts @@ -32,6 +32,10 @@ import { buildPaperclipEnv, renderTemplate, ensureAbsoluteDirectory, + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + joinPromptSections, + renderPaperclipWakePrompt, + stringifyPaperclipWakePayload, } from "@paperclipai/adapter-utils/server-utils"; import { @@ -74,76 +78,65 @@ export function resolveHermesCommand(config: Record): string { // Wake-up prompt builder // --------------------------------------------------------------------------- -const DEFAULT_PROMPT_TEMPLATE = `You are "{{agentName}}", an AI agent employee in a Paperclip-managed company. +const HERMES_DEFAULT_PROMPT_TEMPLATE = [ + 'You are "{{agent.name}}", an AI agent employee in a Paperclip-managed company.', + "", + "Paperclip runtime identity:", + "- Agent ID: {{agent.id}}", + "- Company ID: {{agent.companyId}}", + "- Run ID: {{run.id}}", + "- API base: {{paperclipApiUrl}}", + "", + "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.", + "- Include `-H \"Authorization: Bearer $PAPERCLIP_API_KEY\"` on API requests.", + "- Include `-H \"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\"` on mutating issue requests.", + "- For multiline comments or status updates, preserve newlines with `jq --arg` or a heredoc-fed helper rather than hand-escaping JSON.", + "", + "Safe multiline update pattern:", + "```bash", + "api=\"${PAPERCLIP_API_URL%/}\"", + "case \"$api\" in */api) ;; *) api=\"$api/api\" ;; esac", + "", + "body=$(cat <<'MD'", + "Summary line", + "", + "- Detail one", + "- Detail two", + "MD", + ")", + "jq -n --arg status done --arg comment \"$body\" '{status:$status, comment:$comment}' | \\", + " curl -sS -X PATCH \"$api/issues/{{context.issueId}}\" \\", + " -H \"Authorization: Bearer $PAPERCLIP_API_KEY\" \\", + " -H \"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\" \\", + " -H \"Content-Type: application/json\" \\", + " --data-binary @-", + "```", + "", + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, +].join("\n"); -IMPORTANT: Use \`terminal\` tool with \`curl\` for ALL Paperclip API calls (web_extract and browser cannot access localhost). +function renderConditionalSections(template: string, vars: Record): string { + const isTruthy = (key: string) => { + if (key === "noTask") return !vars.taskId; + const value = vars[key]; + if (Array.isArray(value)) return value.length > 0; + return Boolean(value); + }; + return template.replace( + /\{\{#([a-zA-Z0-9_.-]+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, + (_match, key: string, body: string) => (isTruthy(key) ? body : ""), + ); +} -Your Paperclip identity: - Agent ID: {{agentId}} - Company ID: {{companyId}} - API Base: {{paperclipApiUrl}} - -{{#taskId}} -## Assigned Task - -Issue ID: {{taskId}} -Title: {{taskTitle}} - -{{taskBody}} - -## Workflow - -0. First, check out the issue so others know you are working on it: - \`curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" "{{paperclipApiUrl}}/issues/{{taskId}}/checkout"\` -1. Work on the task using your tools -2. When done, mark the issue as completed: - \`curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" "{{paperclipApiUrl}}/issues/{{taskId}}" -H "Content-Type: application/json" -d '{"status":"done"}'\` -3. Post a completion comment on the issue summarizing what you did: - \`curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" "{{paperclipApiUrl}}/issues/{{taskId}}/comments" -H "Content-Type: application/json" -d '{"body":"DONE: "}'\` -4. If this issue has a parent (check the issue body or comments for references like TRA-XX), post a brief notification on the parent issue so the parent owner knows: - \`curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" "{{paperclipApiUrl}}/issues/PARENT_ISSUE_ID/comments" -H "Content-Type: application/json" -d '{"body":"{{agentName}} completed {{taskId}}. Summary: "}'\` -{{/taskId}} - -{{#commentId}} -## Comment on This Issue - -Someone commented. Read it: - \`curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "{{paperclipApiUrl}}/issues/{{taskId}}/comments/{{commentId}}"\` - -You do NOT need to be assigned to this issue to comment on it. -Address the comment, POST a reply if needed, then continue working. -{{/commentId}} - -{{#noTask}} -## Heartbeat Wake — Check for Work - -First, check your environment for context: - \`echo "TASK_ID=$PAPERCLIP_TASK_ID WAKE_REASON=$PAPERCLIP_WAKE_REASON WAKE_COMMENT_ID=$PAPERCLIP_WAKE_COMMENT_ID"\` - -1. List ALL open issues assigned to you (todo, backlog, in_progress): - \`curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "{{paperclipApiUrl}}/companies/{{companyId}}/issues?assigneeAgentId={{agentId}}" | python3 -c "import sys,json;issues=json.loads(sys.stdin.read());[print(f\\"{i['identifier']} {i['status']:>12} {i['priority']:>6} {i['title']}\\") for i in issues if i['status'] not in ('done','cancelled')]" \` - -2. If issues found, pick the highest priority one that is not done/cancelled and work on it: - - Read the issue details: \`curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "{{paperclipApiUrl}}/issues/ISSUE_ID"\` - - Do the work in the project directory: {{projectName}} - - When done, mark complete and post a comment (see Workflow steps 2-4 above) - -3. If no issues assigned to you, check for unassigned issues: - \`curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "{{paperclipApiUrl}}/companies/{{companyId}}/issues?status=backlog" | python3 -c "import sys,json;issues=json.loads(sys.stdin.read());[print(f\\"{i['identifier']} {i['title']}\\") for i in issues if not i.get('assigneeAgentId')]" \` - If you find a relevant issue, assign it to yourself: - \`curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" "{{paperclipApiUrl}}/issues/ISSUE_ID" -H "Content-Type: application/json" -d '{"assigneeAgentId":"{{agentId}}","status":"todo"}'\` - -4. If truly nothing to do, report briefly what you checked. -{{/noTask}}`; - -function buildPrompt( +export function buildPrompt( ctx: AdapterExecutionContext, config: Record, + options: { resumedSession?: boolean } = {}, ): string { - const template = cfgString(config.promptTemplate) || DEFAULT_PROMPT_TEMPLATE; + const template = cfgString(config.promptTemplate) || HERMES_DEFAULT_PROMPT_TEMPLATE; - // BUG FIX: Read task/comment data from ctx.context (wake context), not ctx.config (adapter config). - // ctx.config only contains adapterConfig (model, provider, timeout); ctx.context has the wake payload. const context = (ctx as any).context || {}; const taskId = cfgString(context.taskId) || cfgString(context.issueId) || cfgString(ctx.config?.taskId); const taskTitle = cfgString(context.taskTitle) || cfgString(ctx.config?.taskTitle) || ""; @@ -164,12 +157,23 @@ function buildPrompt( paperclipApiUrl = paperclipApiUrl.replace(/\/+$/, "") + "/api"; } + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { + resumedSession: options.resumedSession === true, + }); + const paperclipTaskMarkdown = cfgString(context.paperclipTaskMarkdown)?.trim() || ""; + const sessionHandoffMarkdown = cfgString(context.paperclipSessionHandoffMarkdown)?.trim() || ""; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake) || ""; + const vars: Record = { agentId: ctx.agent?.id || "", agentName, companyId: ctx.agent?.companyId || "", companyName, runId: ctx.runId || "", + agent: ctx.agent || {}, + company: { id: ctx.agent?.companyId || "", name: companyName }, + run: { id: ctx.runId || "", source: "on_demand" }, + context, taskId: taskId || "", taskTitle, taskBody, @@ -177,31 +181,22 @@ function buildPrompt( wakeReason, projectName, paperclipApiUrl, + paperclipWakePrompt: wakePrompt, + paperclipTaskMarkdown, + taskContext: paperclipTaskMarkdown, + paperclipWakeJson: wakePayloadJson, + wakePayloadJson, + paperclipApiKeyEnv: "PAPERCLIP_API_KEY", + paperclipRunIdEnv: "PAPERCLIP_RUN_ID", }; - // Handle conditional sections: {{#key}}...{{/key}} - let rendered = template; - - // {{#taskId}}...{{/taskId}} — include if task is assigned - rendered = rendered.replace( - /\{\{#taskId\}\}([\s\S]*?)\{\{\/taskId\}\}/g, - taskId ? "$1" : "", - ); - - // {{#noTask}}...{{/noTask}} — include if no task - rendered = rendered.replace( - /\{\{#noTask\}\}([\s\S]*?)\{\{\/noTask\}\}/g, - taskId ? "" : "$1", - ); - - // {{#commentId}}...{{/commentId}} — include if comment exists - rendered = rendered.replace( - /\{\{#commentId\}\}([\s\S]*?)\{\{\/commentId\}\}/g, - commentId ? "$1" : "", - ); - - // Replace remaining {{variable}} placeholders - return renderTemplate(rendered, vars); + const rendered = renderTemplate(renderConditionalSections(template, vars), vars); + return joinPromptSections([ + wakePrompt, + sessionHandoffMarkdown, + paperclipTaskMarkdown, + rendered, + ]); } // --------------------------------------------------------------------------- @@ -341,6 +336,9 @@ export async function execute( const persistSession = cfgBoolean(config.persistSession) !== false; const worktreeMode = cfgBoolean(config.worktreeMode) === true; const checkpoints = cfgBoolean(config.checkpoints) === true; + const prevSessionId = cfgString( + (ctx.runtime?.sessionParams as Record | null)?.sessionId, + ); // ── Resolve provider (defense in depth) ──────────────────────────────── // Priority chain: @@ -403,7 +401,7 @@ export async function execute( } // ── Build prompt ─────────────────────────────────────────────────────── - let prompt = buildPrompt(ctx, config); + let prompt = buildPrompt(ctx, config, { resumedSession: Boolean(prevSessionId) }); if (agentInstructions) { prompt = agentInstructions + "\n\n---\n\n" + prompt; } @@ -447,10 +445,6 @@ export async function execute( // system is designed for human-attended interactive sessions. args.push("--yolo"); - // Session resume - const prevSessionId = cfgString( - (ctx.runtime?.sessionParams as Record | null)?.sessionId, - ); if (persistSession && prevSessionId) { args.push("--resume", prevSessionId); } @@ -460,8 +454,10 @@ export async function execute( } // ── Build environment ────────────────────────────────────────────────── + const userEnv = config.env as Record | undefined; const env: Record = { ...(process.env as Record), + ...(userEnv && typeof userEnv === "object" ? userEnv : {}), ...buildPaperclipEnv(ctx.agent), }; @@ -478,11 +474,8 @@ export async function execute( if (envWakeReason) env.PAPERCLIP_WAKE_REASON = envWakeReason; const envCommentId = cfgString(ctxContext.commentId) || cfgString(ctxContext.wakeCommentId) || cfgString(ctx.config?.commentId); if (envCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = envCommentId; - - const userEnv = config.env as Record | undefined; - if (userEnv && typeof userEnv === "object") { - Object.assign(env, userEnv); - } + const wakePayloadJson = stringifyPaperclipWakePayload(ctxContext.paperclipWake); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; // ── Resolve working directory ────────────────────────────────────────── const cwd = diff --git a/packages/adapters/hermes-local/src/server/prompt-rendering.test.ts b/packages/adapters/hermes-local/src/server/prompt-rendering.test.ts new file mode 100644 index 00000000000..95cb2cda729 --- /dev/null +++ b/packages/adapters/hermes-local/src/server/prompt-rendering.test.ts @@ -0,0 +1,128 @@ +import { expect, test } from "vitest"; + +import { buildPrompt } from "./execute.js"; + +function baseContext(overrides: Record = {}) { + return { + agent: { + id: "agent-1", + name: "Hermes Engineer", + companyId: "company-1", + }, + runId: "run-1", + config: {}, + context: { + issueId: "issue-1", + paperclipWake: { + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan the Hermes prompt update", + status: "in_progress", + priority: "medium", + workMode: "planning", + }, + checkedOutByHarness: true, + commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 }, + comments: [], + fallbackFetchNeeded: false, + }, + paperclipTaskMarkdown: [ + "Paperclip task context:", + '- Issue: "PAP-3404"', + '- Title: "Plan the Hermes prompt update"', + "", + "Planning mode directive:", + "Make the plan only. Do not write code or perform implementation work.", + "", + "Issue description:", + "```text", + "Use the wake payload as runtime authority.", + "```", + ].join("\n"), + ...overrides, + }, + } as any; +} + +test("renders scoped planning wake authority before the Hermes default workflow", () => { + const prompt = buildPrompt(baseContext(), { + paperclipApiUrl: "http://127.0.0.1:3101/api", + }); + + expect(prompt).toContain("## Paperclip Wake Payload"); + expect(prompt).toContain("- issue: PAP-3404 Plan the Hermes prompt update"); + expect(prompt).toContain("- planning directive: Make the plan only. Do not write code or perform implementation work."); + expect(prompt).toContain("- checkout: already claimed by the harness for this run"); + expect(prompt).toContain("The harness already checked out this issue for the current run."); + expect(prompt).toContain("Issue description:\n```text\nUse the wake payload as runtime authority.\n```"); + expect(prompt).toContain("clear final disposition"); + expect(prompt).toContain("keep `in_progress` only when a live continuation path exists"); + expect(prompt).not.toContain("check for unassigned issues"); + expect(prompt).not.toContain("status=backlog"); +}); + +test("renders comment wake batch guidance without defaulting to a full-thread refetch", () => { + const prompt = buildPrompt(baseContext({ + wakeCommentId: "comment-1", + paperclipWake: { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan the Hermes prompt update", + status: "in_progress", + priority: "medium", + workMode: "standard", + }, + latestCommentId: "comment-1", + commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, + comments: [{ id: "comment-1", body: "Please tighten the prompt.", createdAt: "2026-06-23T00:00:00.000Z" }], + fallbackFetchNeeded: false, + }, + }), {}); + + expect(prompt).toContain("Use this inline wake data first before refetching the issue thread."); + expect(prompt).toContain("Only fetch the API thread when `fallbackFetchNeeded` is true"); + expect(prompt).toContain("New comments in order:"); + expect(prompt).toContain("Please tighten the prompt."); + expect(prompt).toContain("- fallback fetch needed: no"); +}); + +test("keeps authoritative parent and ancestor context from task markdown", () => { + const prompt = buildPrompt(baseContext({ + paperclipTaskMarkdown: [ + "Paperclip task context:", + '- Issue: "PAP-3404"', + "", + "Authoritative parent / ancestor context:", + "- Parent: PAP-11724 Optimize prompt traces (in_progress) [medium]", + "- Ancestor 2: PAP-11721 Fetch raw traces (done) [medium]", + ].join("\n"), + }), {}); + + expect(prompt).toContain("Authoritative parent / ancestor context:"); + expect(prompt).toContain("- Parent: PAP-11724 Optimize prompt traces (in_progress) [medium]"); + expect(prompt).not.toContain("check the issue body or comments for references"); +}); + +test("preserves custom prompt templates while exposing runtime and wake variables", () => { + const prompt = buildPrompt(baseContext(), { + paperclipApiUrl: "http://paperclip.local/api", + promptTemplate: [ + "CUSTOM TEMPLATE", + "agent={{agent.name}}", + "api={{paperclipApiUrl}}", + "task={{paperclipTaskMarkdown}}", + "wake={{wakePayloadJson}}", + ].join("\n"), + }); + + expect(prompt).toContain("CUSTOM TEMPLATE"); + expect(prompt).toContain("agent=Hermes Engineer"); + expect(prompt).toContain("api=http://paperclip.local/api"); + expect(prompt).toContain("task=Paperclip task context:"); + expect(prompt).toContain('"reason":"issue_assigned"'); + expect(prompt).toContain("## Paperclip Wake Payload"); +}); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 197b11d8c6e..83475c152ee 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3140,6 +3140,13 @@ export function buildPaperclipTaskMarkdown(input: { workMode?: string | null; description?: string | null; } | null; + ancestors?: Array<{ + id: string; + identifier?: string | null; + title?: string | null; + status?: string | null; + priority?: string | null; + }> | null; wakeComment?: { id: string; body: string; @@ -3160,6 +3167,7 @@ export function buildPaperclipTaskMarkdown(input: { return [fence + "text", value, fence].join("\n"); }; const issue = input.issue; + const ancestors = (input.ancestors ?? []).slice(0, 6); const wakeComment = input.wakeComment ?? null; const acceptedPlanContinuation = !wakeComment && @@ -3212,6 +3220,19 @@ export function buildPaperclipTaskMarkdown(input: { lines.push("", "Issue description:", fenceTaskText(description)); } } + if (ancestors.length > 0) { + lines.push("", "Authoritative parent / ancestor context:"); + for (const [index, ancestor] of ancestors.entries()) { + const label = ancestor.identifier || ancestor.id; + const status = ancestor.status ? ` (${ancestor.status})` : ""; + const priority = ancestor.priority ? ` [${ancestor.priority}]` : ""; + const title = ancestor.title ? ` ${ancestor.title}` : ""; + lines.push(`- ${index === 0 ? "Parent" : `Ancestor ${index + 1}`}: ${label}${title}${status}${priority}`); + } + if ((input.ancestors ?? []).length > ancestors.length) { + lines.push(`- [ancestor context truncated after ${ancestors.length} entries]`); + } + } if (wakeComment?.body.trim()) { lines.push("", "Latest wake comment:", fenceTaskText(wakeComment.body.trim())); } @@ -8647,6 +8668,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) wakeCommentContext && !exposeLowTrustRaw ? sanitizeQuarantinedCommentForHigherTrust(wakeCommentContext) : wakeCommentContext; + const issueAncestors = issueRef + ? await issuesSvc.getAncestors(issueRef.id) + : []; if (continuationSummary) { context.paperclipContinuationSummary = { key: safeContinuationSummary!.key, @@ -8692,6 +8716,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) description: issueRef.description, } : null, + ancestors: issueAncestors, wakeComment: safeWakeCommentContext, interaction: { kind: readNonEmptyString(context.interactionKind),