Preserve chat policy across fresh and resumed provider sessions
This commit is contained in:
parent
3556fa25f1
commit
abacbdfd2f
|
|
@ -14,6 +14,7 @@ import {
|
|||
buildPaperclipEnv,
|
||||
buildRuntimeToolsEnv,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
materializePaperclipSkillCopy,
|
||||
PAPERCLIP_OPERATIONAL_SKILL_KEY,
|
||||
refreshPaperclipWorkspaceEnvForExecution,
|
||||
|
|
@ -82,6 +83,9 @@ describe("runtime connection tool delivery", () => {
|
|||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain(
|
||||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
);
|
||||
expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).toContain(CONNECTION_INTENT_AGENT_GUIDANCE);
|
||||
expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).not.toContain("Execution contract:");
|
||||
expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).not.toContain("child issues");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -893,6 +897,30 @@ describe("runChildProcess", () => {
|
|||
});
|
||||
|
||||
describe("renderPaperclipWakePrompt", () => {
|
||||
it("leaves conversation disposition and accepted-plan handoff to the injected chat policy", () => {
|
||||
const payload = {
|
||||
reason: "issue_commented",
|
||||
issue: { id: "chat", workMode: "planning", status: "in_progress" },
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
comments: [],
|
||||
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
|
||||
fallbackFetchNeeded: false,
|
||||
};
|
||||
const ordinary = renderPaperclipWakePrompt(payload, { resumedSession: true });
|
||||
expect(ordinary).toContain("Execution contract:");
|
||||
expect(ordinary).toContain("Create child issues from the approved plan");
|
||||
for (const resumedSession of [false, true]) {
|
||||
const chat = renderPaperclipWakePrompt(payload, {
|
||||
resumedSession, conversationMode: true, includeExecutionContract: true,
|
||||
});
|
||||
expect(chat).not.toContain("Execution contract:");
|
||||
expect(chat).not.toContain("clear final disposition");
|
||||
expect(chat).not.toContain("Create child issues");
|
||||
expect(chat).not.toContain("you may create child implementation issues");
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves and renders the issue description in structured wake payloads", () => {
|
||||
const payload = {
|
||||
reason: "issue_assigned",
|
||||
|
|
|
|||
|
|
@ -209,6 +209,18 @@ export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
|
|||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
].join("\n");
|
||||
|
||||
// Chat behavior is supplied centrally by the server's task-context markdown.
|
||||
// Keep the ordinary task's completion/delegation contract out of this template.
|
||||
export const DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE = [
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip conversation using the supplied chat mode directive.",
|
||||
"Use available tools and assigned skills as needed; respect budget, pause/cancel, approval gates, and company boundaries.",
|
||||
"Prefer the smallest verification that proves the action. Use PAPERCLIP_SCRATCH_DIR / PAPERCLIP_RUN_SCRATCH_DIR for temporary scratch files.",
|
||||
"After 2 consecutive failures of the same control-plane write, stop retrying that write for the rest of the turn. Report the failure honestly; never claim an unconfirmed mutation succeeded.",
|
||||
"Never create probe or throwaway issue-thread interactions. Every interaction must carry a real, answerable prompt; withdraw one you no longer need.",
|
||||
"",
|
||||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
].join("\n");
|
||||
|
||||
export const WATCHDOG_DEFAULT_MANDATE = [
|
||||
"You are running as a task watchdog, not as the original deliverable worker.",
|
||||
"Your mission is to keep the watched issue tree moving by verifying stopped work, not by trusting agent claims.",
|
||||
|
|
@ -1552,6 +1564,9 @@ export function renderPaperclipWakePrompt(
|
|||
options: {
|
||||
resumedSession?: boolean;
|
||||
includeExecutionContract?: boolean;
|
||||
// Conversation policy arrives in the server-owned task markdown. Generic
|
||||
// task disposition and child-delegation instructions conflict with it.
|
||||
conversationMode?: boolean;
|
||||
// Set by adapters whose prompt already carries the task-context markdown
|
||||
// (the authoritative, uncapped brief) so the description is not delivered
|
||||
// twice in one prompt.
|
||||
|
|
@ -1564,7 +1579,8 @@ export function renderPaperclipWakePrompt(
|
|||
// 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 includeExecutionContract = options.conversationMode !== true &&
|
||||
(resumedSession || options.includeExecutionContract === true);
|
||||
const hasWakeCommentBatch =
|
||||
normalized.comments.length > 0 || normalized.includedCount > 0 || normalized.requestedCount > 0;
|
||||
const executionStage = normalized.executionStage;
|
||||
|
|
@ -1749,7 +1765,7 @@ export function renderPaperclipWakePrompt(
|
|||
lines.push(`- checkbox selection ids: ${selectedOptionIds}`);
|
||||
lines.push(`- checkbox selection options: ${selectedOptions}`);
|
||||
}
|
||||
if (normalized.issue?.workMode === "planning" && !normalized.taskWatchdog) {
|
||||
if (normalized.issue?.workMode === "planning" && !normalized.taskWatchdog && options.conversationMode !== true) {
|
||||
const hasWakeComments = normalized.comments.length > 0;
|
||||
const acceptedPlanContinuation =
|
||||
!hasWakeComments &&
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { buildSkillLibraryManifestMarkdown } from "@paperclipai/adapter-utils/skill-library-manifest";
|
||||
import {
|
||||
|
|
@ -428,7 +429,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const effort = asString(config.effort, "");
|
||||
const chrome = asBoolean(config.chrome, false);
|
||||
|
|
@ -845,6 +848,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const taskContextNote = selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) });
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
resumedSession: Boolean(sessionId),
|
||||
conversationMode: context.conversationMode === true,
|
||||
// The task-context markdown is the authoritative brief on this lane; keep
|
||||
// the wake prompt's description copy out so the prompt carries it once.
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
|
|
|
|||
|
|
@ -45,9 +45,11 @@ import {
|
|||
readPaperclipIssueWorkModeFromContext,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
|
|
@ -587,7 +589,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "codex");
|
||||
const model = asString(config.model, "");
|
||||
|
|
@ -1105,7 +1109,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const taskContextNote = selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) });
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
resumedSession: Boolean(sessionId),
|
||||
conversationMode: context.conversationMode === true,
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix;
|
||||
instructionsChars = promptInstructionsPrefix.length;
|
||||
|
|
@ -1188,6 +1197,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
wakePrompt,
|
||||
codexFallbackHandoffNote,
|
||||
sessionHandoffNote,
|
||||
taskContextNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
|
|
@ -1196,6 +1206,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { runChildProcess } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { execute } from "@paperclipai/adapter-codex-local/server";
|
||||
import { buildPaperclipTaskMarkdown } from "../services/heartbeat.js";
|
||||
import { AGENT_CHAT_DIRECTIVE } from "../services/agent-conversations.js";
|
||||
|
||||
async function writeFakeCodexCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
|
|
@ -1210,7 +1212,7 @@ process.exit(1);
|
|||
}
|
||||
});
|
||||
|
||||
it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => {
|
||||
it.each([{ conversationMode: false, resumedSession: true }, { conversationMode: true, resumedSession: true }, { conversationMode: true, resumedSession: false }])("retains current task policy (conversation=$conversationMode, resumed=$resumedSession)", async ({ conversationMode, resumedSession }) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-resume-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
|
|
@ -1224,6 +1226,14 @@ process.exit(1);
|
|||
process.env.HOME = root;
|
||||
await seedSharedCodexAuth(root);
|
||||
|
||||
const policy = conversationMode
|
||||
? buildPaperclipTaskMarkdown({
|
||||
issue: { id: "issue-1", title: "Chat", workMode: "planning", conversationAgentId: "agent-1" },
|
||||
interaction: { kind: "request_confirmation", status: "rejected" },
|
||||
planReview: { status: "rejected", reason: "Revise the final note." },
|
||||
includeDescription: false,
|
||||
})
|
||||
: "Current ordinary task policy";
|
||||
let invocationPrompt = "";
|
||||
let invocationNotes: string[] = [];
|
||||
let promptMetrics: Record<string, number> = {};
|
||||
|
|
@ -1240,7 +1250,7 @@ process.exit(1);
|
|||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: {
|
||||
sessionId: "codex-session-1",
|
||||
sessionId: resumedSession ? "codex-session-1" : null,
|
||||
cwd: workspace,
|
||||
},
|
||||
sessionDisplayId: null,
|
||||
|
|
@ -1254,9 +1264,12 @@ process.exit(1);
|
|||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
promptTemplate: conversationMode ? undefined : "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
conversationMode,
|
||||
paperclipTaskMarkdown: `Full description that must not replay\n${policy}`,
|
||||
paperclipTaskMarkdownCompact: policy,
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "issue_commented",
|
||||
|
|
@ -1304,18 +1317,35 @@ process.exit(1);
|
|||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.argv).toEqual(expect.arrayContaining(["resume", "codex-session-1", "-"]));
|
||||
expect(capture.prompt).toContain("## Paperclip Resume Delta");
|
||||
if (resumedSession) expect(capture.argv).toEqual(expect.arrayContaining(["resume", "codex-session-1", "-"]));
|
||||
else expect(capture.argv).not.toContain("resume");
|
||||
expect(capture.prompt).toContain(resumedSession ? "## Paperclip Resume Delta" : "## Paperclip Wake Payload");
|
||||
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(capture.prompt).toContain("Second comment");
|
||||
expect(capture.prompt).toContain(policy);
|
||||
expect(invocationPrompt).toContain(policy);
|
||||
if (resumedSession) expect(capture.prompt).not.toContain("Full description that must not replay");
|
||||
else expect(capture.prompt).toContain("Full description that must not replay");
|
||||
expect(promptMetrics.taskContextChars).toBe(resumedSession ? policy.length : `Full description that must not replay\n${policy}`.length);
|
||||
if (conversationMode) {
|
||||
expect(invocationPrompt).toContain(AGENT_CHAT_DIRECTIVE);
|
||||
expect(invocationPrompt).toContain("baseRevisionId set to that latestRevisionId");
|
||||
expect(capture.prompt).not.toContain("Execution contract:");
|
||||
expect(capture.prompt).not.toContain("Use child issues");
|
||||
} else {
|
||||
expect(capture.prompt).toContain("Execution contract:");
|
||||
}
|
||||
expect(capture.prompt).not.toContain("Follow the paperclip heartbeat.");
|
||||
expect(capture.prompt).not.toContain("You are managed instructions.");
|
||||
expect(invocationPrompt).toContain("## Paperclip Resume Delta");
|
||||
expect(invocationNotes).toContain(
|
||||
"Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.",
|
||||
);
|
||||
expect(promptMetrics.instructionsChars).toBe(0);
|
||||
expect(promptMetrics.heartbeatPromptChars).toBe(0);
|
||||
if (resumedSession) {
|
||||
expect(capture.prompt).not.toContain("You are managed instructions.");
|
||||
expect(invocationPrompt).toContain("## Paperclip Resume Delta");
|
||||
expect(invocationNotes).toContain("Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.");
|
||||
expect(promptMetrics.instructionsChars).toBe(0);
|
||||
expect(promptMetrics.heartbeatPromptChars).toBe(0);
|
||||
} else {
|
||||
expect(capture.prompt).toContain("You are managed instructions.");
|
||||
expect(promptMetrics.heartbeatPromptChars).toBeGreaterThan(0);
|
||||
}
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
|
|
|
|||
|
|
@ -20248,7 +20248,7 @@ export function heartbeatService(
|
|||
...nativeExecution,
|
||||
task: {
|
||||
...nativeExecution.task,
|
||||
prompt: `${nativeExecution.task.prompt}\n\n${renderPaperclipWakePrompt({ executionContinuation }, { resumedSession: true })}`,
|
||||
prompt: `${nativeExecution.task.prompt}\n\n${renderPaperclipWakePrompt({ executionContinuation }, { resumedSession: true, conversationMode: context.conversationMode === true })}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -20376,6 +20376,7 @@ export function heartbeatService(
|
|||
`# ${issueRef.identifier ?? issueRef.id}: ${issueRef.title}`,
|
||||
wakePayload: context.paperclipWake,
|
||||
resumedSession: previousNativeRun !== null,
|
||||
conversationMode: context.conversationMode === true,
|
||||
agentId: agent.id,
|
||||
workspace: {
|
||||
// Projectless paperclip_runner tasks still have a resolved local cwd. Bind that
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export function buildNativeExecutionInput(input: {
|
|||
*/
|
||||
wakePayload?: unknown;
|
||||
resumedSession?: boolean;
|
||||
conversationMode?: boolean;
|
||||
agentId: string;
|
||||
workspace: {
|
||||
id: string;
|
||||
|
|
@ -89,6 +90,7 @@ export function buildNativeExecutionInput(input: {
|
|||
: null;
|
||||
const wakePrompt = renderPaperclipWakePrompt(input.wakePayload, {
|
||||
resumedSession: input.resumedSession === true,
|
||||
conversationMode: input.conversationMode === true,
|
||||
suppressIssueDescription: input.taskPrompt.trim().length > 0,
|
||||
});
|
||||
const taskPrompt = [wakePrompt, input.taskPrompt.trim()]
|
||||
|
|
|
|||
|
|
@ -405,7 +405,7 @@ describe("buildNativeExecutionInput wake projection", () => {
|
|||
.not.toMatch(/OPENAI_API_KEY|ANTHROPIC_API_KEY|AWS_SECRET_ACCESS_KEY|PAPERCLIP_API_KEY/);
|
||||
});
|
||||
|
||||
it("places child completion summaries in the closed provider prompt", () => {
|
||||
it.each([false, true])("projects wake context with the appropriate execution contract (conversation=%s)", (conversationMode) => {
|
||||
const input = buildNativeExecutionInput({
|
||||
companyId,
|
||||
runId: currentRunId,
|
||||
|
|
@ -439,6 +439,7 @@ describe("buildNativeExecutionInput wake projection", () => {
|
|||
checkedOutByHarness: true,
|
||||
},
|
||||
resumedSession: true,
|
||||
conversationMode,
|
||||
agentId,
|
||||
workspace: {
|
||||
id: currentRunId,
|
||||
|
|
@ -463,6 +464,8 @@ describe("buildNativeExecutionInput wake projection", () => {
|
|||
runtimeContext: nativeRuntimeContextFixture(),
|
||||
});
|
||||
|
||||
expect(input.task.prompt.includes("Execution contract:")).toBe(!conversationMode);
|
||||
expect(input.task.prompt.includes("Use child issues")).toBe(!conversationMode);
|
||||
expect(input.task.prompt).toContain("## Paperclip Resume Delta");
|
||||
expect(input.task.prompt).toContain("reason: issue_children_completed");
|
||||
expect(input.task.prompt).toContain("DOT-147 Build utility (done)");
|
||||
|
|
|
|||
Loading…
Reference in New Issue