feat: optimize heartbeat token usage

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-13 08:49:11 -05:00
parent 2246d5f1eb
commit 7d1748b3a7
28 changed files with 800 additions and 95 deletions

View File

@@ -112,6 +112,16 @@ export function renderTemplate(template: string, data: Record<string, unknown>)
return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path));
}
export function joinPromptSections(
sections: Array<string | null | undefined>,
separator = "\n\n",
) {
return sections
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean)
.join(separator);
}
export function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
const redacted: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {

View File

@@ -99,6 +99,7 @@ export interface AdapterInvocationMeta {
commandNotes?: string[];
env?: Record<string, string>;
prompt?: string;
promptMetrics?: Record<string, number>;
context?: Record<string, unknown>;
}

View File

@@ -12,6 +12,7 @@ import {
parseObject,
parseJson,
buildPaperclipEnv,
joinPromptSections,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
@@ -363,7 +364,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
const prompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -371,7 +373,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
renderedBootstrapPrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildClaudeArgs = (resumeSessionId: string | null) => {
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
@@ -416,6 +435,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
commandNotes,
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -67,6 +67,7 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
if (v.chrome) ac.chrome = true;

View File

@@ -18,6 +18,7 @@ import {
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
joinPromptSections,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
@@ -278,6 +279,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let instructionsPrefix = "";
let instructionsChars = 0;
if (instructionsFilePath) {
try {
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
@@ -285,6 +287,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
instructionsChars = instructionsPrefix.length;
await onLog(
"stderr",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
@@ -309,7 +312,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
];
})();
const renderedPrompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -317,8 +321,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
const prompt = `${instructionsPrefix}${renderedPrompt}`;
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["exec", "--json"];
@@ -346,6 +368,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}),
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -71,6 +71,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_CODEX_LOCAL_MODEL;
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
ac.timeoutSec = 0;

View File

@@ -17,6 +17,7 @@ import {
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
joinPromptSections,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
@@ -268,6 +269,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let instructionsPrefix = "";
let instructionsChars = 0;
if (instructionsFilePath) {
try {
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
@@ -275,6 +277,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
instructionsChars = instructionsPrefix.length;
await onLog(
"stderr",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
@@ -307,7 +310,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return notes;
})();
const renderedPrompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -315,9 +319,29 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`;
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
paperclipEnvNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
runtimeNoteChars: paperclipEnvNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["-p", "--output-format", "stream-json", "--workspace", cwd];
@@ -340,6 +364,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
commandArgs: args,
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -62,6 +62,7 @@ export function buildCursorLocalConfig(v: CreateConfigValues): Record<string, un
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_CURSOR_LOCAL_MODEL;
const mode = normalizeMode(v.thinkingEffort);
if (mode) ac.mode = mode;

View File

@@ -13,6 +13,7 @@ import {
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
joinPromptSections,
ensurePathInEnv,
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
@@ -268,7 +269,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return notes;
})();
const renderedPrompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -276,10 +278,31 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const apiAccessNote = renderApiAccessNote(env);
const prompt = `${instructionsPrefix}${paperclipEnvNote}${apiAccessNote}${renderedPrompt}`;
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
paperclipEnvNote,
apiAccessNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["--output-format", "stream-json"];
@@ -309,6 +332,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
)),
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -56,6 +56,7 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
ac.timeoutSec = 0;
ac.graceSec = 15;

View File

@@ -9,6 +9,7 @@ import {
asStringArray,
parseObject,
buildPaperclipEnv,
joinPromptSections,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
@@ -233,7 +234,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
];
})();
const renderedPrompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -241,8 +243,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
const prompt = `${instructionsPrefix}${renderedPrompt}`;
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["run", "--format", "json"];
@@ -264,6 +284,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
env: redactEnvForLogs(env),
prompt,
promptMetrics,
context,
});
}

View File

@@ -55,6 +55,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)

View File

@@ -9,6 +9,7 @@ import {
asStringArray,
parseObject,
buildPaperclipEnv,
joinPromptSections,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
@@ -270,7 +271,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
systemPromptExtension = promptTemplate;
}
const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@@ -278,18 +280,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
// User prompt is simple - just the rendered prompt template without instructions
const userPrompt = renderTemplate(promptTemplate, {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
});
};
const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, templateData);
const renderedHeartbeatPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!canResumeSession && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const userPrompt = joinPromptSections([
renderedBootstrapPrompt,
sessionHandoffNote,
renderedHeartbeatPrompt,
]);
const promptMetrics = {
systemPromptChars: renderedSystemPromptExtension.length,
promptChars: userPrompt.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedHeartbeatPrompt.length,
};
const commandNotes = (() => {
if (!resolvedInstructionsFilePath) return [] as string[];
@@ -345,6 +355,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
commandArgs: args,
env: redactEnvForLogs(env),
prompt: userPrompt,
promptMetrics,
context,
});
}

View File

@@ -48,6 +48,7 @@ export function buildPiLocalConfig(v: CreateConfigValues): Record<string, unknow
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.thinking = v.thinkingEffort;

View File

@@ -78,6 +78,7 @@ export const wakeAgentSchema = z.object({
reason: z.string().optional().nullable(),
payload: z.record(z.unknown()).optional().nullable(),
idempotencyKey: z.string().optional().nullable(),
forceFreshSession: z.boolean().optional().default(false),
});
export type WakeAgent = z.infer<typeof wakeAgentSchema>;