From c8253e36412abf307cdb67719388e0e22547893a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:41:52 -0500 Subject: [PATCH] fix(adapters): inject execution contract once per fresh heartbeat (#9469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip coordinates AI-agent work through repeated heartbeat runs. > - Adapter prompts combine a default heartbeat template with scoped wake context. > - Fresh heartbeats received the same execution contract from both layers, wasting prompt tokens and obscuring which layer owns the contract. > - Resume deltas and template-less adapters do not share that composition path, so removing the wake-payload copy unconditionally would drop required guidance. > - Empty comment batches also emitted instructions and metadata that only matter when comments exist. > - This pull request makes execution-contract inclusion explicit by prompt path, preserves OpenClaw gateway behavior, and suppresses no-op comment boilerplate. > - The benefit is one contract per heartbeat path and roughly 300 fewer prompt tokens on a fresh zero-comment wake. ## Linked Issues or Issue Description - Fixes #9221 - Refs #9200 - Refs #7634 ## What Changed - Stop emitting the execution-contract paragraph from fresh scoped wake payloads because the default heartbeat template already contains the full contract. - Keep the contract in resume deltas, and add `includeExecutionContract` for adapters that do not render the default heartbeat template. - Opt `openclaw-gateway` into wake-payload contract rendering so template-less gateway runs retain the guidance. - Omit comment-batch acknowledgement/fetch guidance and empty `pending comments` / `latest comment id` metadata when a fresh wake has no pending comments. - Add regression and acceptance coverage proving composed fresh prompts contain `Execution contract` exactly once while resume and template-less paths retain it. Measured effect: the fresh zero-comment wake block drops from 1,840 to 855 characters (about 300 tokens saved per fresh heartbeat; about 220 on comment wakes), and the composed fresh prompt contains `Execution contract` once instead of twice. ## Verification - `npx vitest run packages/adapter-utils/src/server-utils.test.ts` — 63 passed - `npx vitest run server/src/__tests__/codex-local-execute.test.ts` — 13 passed - `npx vitest run server/src/__tests__/heartbeat-comment-wake-batching.test.ts server/src/__tests__/openclaw-gateway-adapter.test.ts server/src/__tests__/low-trust-red-team-routes.test.ts` — 27 passed - `pnpm --filter @paperclipai/adapter-utils typecheck` — passed - `pnpm --filter @paperclipai/adapter-openclaw-gateway typecheck` — passed ## Risks - Low risk: prompt text and adapter composition only; no database or API migration. - The main compatibility risk is a template-less adapter losing the contract. The explicit option and OpenClaw gateway regression coverage protect the known template-less path. - External adapters that call `renderPaperclipWakePrompt` directly can opt into `includeExecutionContract: true` when they do not render the default template. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.4, reasoning mode with tool use and code execution; context-window size is not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .../adapter-utils/src/server-utils.test.ts | 102 ++++++++++++++++-- packages/adapter-utils/src/server-utils.ts | 51 ++++++--- .../openclaw-gateway/src/server/execute.ts | 6 +- .../src/__tests__/codex-local-execute.test.ts | 4 +- 4 files changed, 138 insertions(+), 25 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 7c7071030b..0b59b30919 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -727,7 +727,7 @@ describe("renderPaperclipWakePrompt", () => { ); }); - it("adds the execution contract to scoped wake prompts", () => { + it("leaves the execution contract to the heartbeat template on fresh scoped wake prompts", () => { const prompt = renderPaperclipWakePrompt({ reason: "issue_assigned", issue: { @@ -746,11 +746,101 @@ describe("renderPaperclipWakePrompt", () => { }); expect(prompt).toContain("## Paperclip Wake Payload"); - expect(prompt).toContain("Execution contract: take concrete action in this heartbeat"); - expect(prompt).toContain("clear final disposition"); - expect(prompt).toContain("evidence, not valid liveness paths by themselves"); - expect(prompt).toContain("Use child issues for long or parallel delegated work instead of polling"); - expect(prompt).toContain("named unblock owner/action"); + expect(prompt).not.toContain("Execution contract:"); + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Execution contract:"); + }); + + it("adds the execution contract to resume delta prompts and opted-in fresh prompts", () => { + const payload = { + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-1580", + title: "Update prompts", + status: "in_progress", + }, + commentWindow: { + requestedCount: 0, + includedCount: 0, + missingCount: 0, + }, + comments: [], + fallbackFetchNeeded: false, + }; + + for (const prompt of [ + renderPaperclipWakePrompt(payload, { resumedSession: true }), + renderPaperclipWakePrompt(payload, { includeExecutionContract: true }), + ]) { + expect(prompt).toContain("Execution contract: take concrete action in this heartbeat"); + expect(prompt).toContain("clear final disposition"); + expect(prompt).toContain("evidence, not valid liveness paths by themselves"); + expect(prompt).toContain("Use child issues for long or parallel delegated work instead of polling"); + expect(prompt).toContain("named unblock owner/action"); + } + }); + + it("keeps exactly one execution contract in a composed fresh heartbeat prompt", () => { + const wakePrompt = renderPaperclipWakePrompt({ + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-1580", + title: "Update prompts", + status: "in_progress", + }, + commentWindow: { + requestedCount: 0, + includedCount: 0, + missingCount: 0, + }, + comments: [], + fallbackFetchNeeded: false, + }); + const composed = [wakePrompt, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE].join("\n\n"); + expect(composed.match(/Execution contract/g)).toHaveLength(1); + }); + + it("trims comment-batch boilerplate on fresh wakes with zero pending comments", () => { + const base = { + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-1580", + title: "Update prompts", + status: "in_progress", + }, + commentWindow: { + requestedCount: 0, + includedCount: 0, + missingCount: 0, + }, + comments: [], + fallbackFetchNeeded: false, + }; + + const zeroCommentPrompt = renderPaperclipWakePrompt(base); + expect(zeroCommentPrompt).not.toContain("acknowledge the latest comment"); + expect(zeroCommentPrompt).not.toContain("Only fetch the API thread"); + expect(zeroCommentPrompt).not.toContain("- pending comments:"); + expect(zeroCommentPrompt).not.toContain("- latest comment id:"); + expect(zeroCommentPrompt).toContain("- fallback fetch needed: no"); + + const commentPrompt = renderPaperclipWakePrompt({ + ...base, + reason: "issue_commented", + commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, + comments: [{ id: "comment-1", body: "Please fix", authorType: "user" }], + latestCommentId: "comment-1", + }); + expect(commentPrompt).toContain("acknowledge the latest comment"); + expect(commentPrompt).toContain("Only fetch the API thread"); + expect(commentPrompt).toContain("- pending comments: 1/1"); + expect(commentPrompt).toContain("- latest comment id: comment-1"); + + const fallbackPrompt = renderPaperclipWakePrompt({ ...base, fallbackFetchNeeded: true }); + expect(fallbackPrompt).toContain("Only fetch the API thread"); + expect(fallbackPrompt).toContain("- fallback fetch needed: yes"); }); it("renders the execution workspace branch guard only on non-resumed sessions", () => { diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 5a4c1e04cf..5c5a9c5ee7 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1263,11 +1263,17 @@ export function readPaperclipIssueWorkModeFromContext(value: unknown): string | export function renderPaperclipWakePrompt( value: unknown, - options: { resumedSession?: boolean } = {}, + options: { resumedSession?: boolean; includeExecutionContract?: boolean } = {}, ): string { const normalized = normalizePaperclipWakePayload(value); if (!normalized) return ""; const resumedSession = options.resumedSession === true; + // The heartbeat prompt template already carries the execution contract on + // fresh sessions; only resume deltas (which replace the template) and + // template-less adapters need the wake-payload copy. + const includeExecutionContract = resumedSession || options.includeExecutionContract === true; + const hasWakeCommentBatch = + normalized.comments.length > 0 || normalized.includedCount > 0 || normalized.requestedCount > 0; const executionStage = normalized.executionStage; const principalLabel = (principal: PaperclipWakeExecutionPrincipal | null) => { if (!principal || !principal.type) return "unknown"; @@ -1294,6 +1300,23 @@ export function renderPaperclipWakePrompt( } }; + const executionContractLines = includeExecutionContract + ? [ + "Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress and then give the issue a clear final disposition before ending the heartbeat: `done`, `in_review` with a real reviewer/approval/interaction path, `blocked` with first-class blockers or a named unblock owner/action, delegated follow-up issues with blockers, or `in_progress` only when a live continuation path exists. Use child issues for long or parallel delegated work instead of polling. Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.", + "", + ] + : []; + const wakeSummaryLines = [ + `- reason: ${normalized.reason ?? "unknown"}`, + `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, + ...(hasWakeCommentBatch + ? [ + `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, + `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, + ] + : []), + `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, + ]; const lines = resumedSession ? [ "## Paperclip Resume Delta", @@ -1303,30 +1326,24 @@ export function renderPaperclipWakePrompt( "Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.", "Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.", "", - "Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress and then give the issue a clear final disposition before ending the heartbeat: `done`, `in_review` with a real reviewer/approval/interaction path, `blocked` with first-class blockers or a named unblock owner/action, delegated follow-up issues with blockers, or `in_progress` only when a live continuation path exists. Use child issues for long or parallel delegated work instead of polling. Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.", - "", - `- reason: ${normalized.reason ?? "unknown"}`, - `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, - `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, - `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, - `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, + ...executionContractLines, + ...wakeSummaryLines, ] : [ "## Paperclip Wake Payload", "", "Treat this wake payload as the highest-priority change for the current heartbeat.", "This heartbeat is scoped to the issue below. Do not switch to another issue until you have handled this wake.", - "Before generic repo exploration or boilerplate heartbeat updates, acknowledge the latest comment and explain how it changes your next action.", + ...(hasWakeCommentBatch + ? ["Before generic repo exploration or boilerplate heartbeat updates, acknowledge the latest comment and explain how it changes your next action."] + : []), "Use this inline wake data first before refetching the issue thread.", - "Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.", + ...(hasWakeCommentBatch || normalized.fallbackFetchNeeded + ? ["Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch."] + : []), "", - "Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress and then give the issue a clear final disposition before ending the heartbeat: `done`, `in_review` with a real reviewer/approval/interaction path, `blocked` with first-class blockers or a named unblock owner/action, delegated follow-up issues with blockers, or `in_progress` only when a live continuation path exists. Use child issues for long or parallel delegated work instead of polling. Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.", - "", - `- reason: ${normalized.reason ?? "unknown"}`, - `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, - `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, - `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, - `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, + ...executionContractLines, + ...wakeSummaryLines, ]; if (normalized.issue?.status) { diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 91bef0081e..ab84aa7de0 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -1084,7 +1084,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(capture.prompt).toContain("## Paperclip Wake Payload"); expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake."); expect(capture.prompt).toContain("- issue: PAP-1201 Fix gallery opening for inline images"); - expect(capture.prompt).toContain("- pending comments: 0/0"); + expect(capture.prompt).not.toContain("- pending comments:"); + expect(capture.prompt).not.toContain("acknowledge the latest comment"); + expect(capture.prompt).not.toContain("Execution contract:"); expect(capture.prompt).toContain("- issue status: in_progress"); expect(capture.prompt).toContain("- checkout: already claimed by the harness for this run"); expect(capture.prompt).toContain("The harness already checked out this issue for the current run.");