fix(adapters): inject execution contract once per fresh heartbeat (#9469)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-12 20:41:52 -05:00 committed by GitHub
parent 5dff52631d
commit c8253e3641
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 138 additions and 25 deletions

View File

@ -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", () => {

View File

@ -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) {

View File

@ -1084,7 +1084,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const wakePayload = buildWakePayload(ctx);
const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload);
const structuredWakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake);
// No heartbeat prompt template is sent over the gateway, so the wake prompt
// must carry the execution contract itself.
const structuredWakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake, {
includeExecutionContract: true,
});
const structuredWakeJson = stringifyPaperclipWakePayload(ctx.context.paperclipWake);
const wakeText = buildWakeText(
wakePayload,

View File

@ -959,7 +959,9 @@ describe("codex execute", () => {
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.");