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:
parent
5dff52631d
commit
c8253e3641
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
Loading…
Reference in New Issue