* cron: skip delivery for HEARTBEAT_OK responses When an isolated cron job has deliver:true, skip message delivery if the response is just HEARTBEAT_OK (or contains HEARTBEAT_OK at edges with short remaining content <= 30 chars). This allows cron jobs to silently ack when nothing to report but still deliver actual content when there is something meaningful to say. Media is still delivered even if text is HEARTBEAT_OK, since the presence of media indicates there's something to share. * fix(heartbeat): make ack padding configurable * chore(deps): update to latest --------- Co-authored-by: Josh Lehman <josh@martian.engineering>
163 lines
4.7 KiB
TypeScript
163 lines
4.7 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import type {
|
|
AgentMessage,
|
|
AgentToolResult,
|
|
} from "@mariozechner/pi-agent-core";
|
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
import {
|
|
normalizeThinkLevel,
|
|
type ThinkLevel,
|
|
} from "../auto-reply/thinking.js";
|
|
|
|
import { sanitizeContentBlocksImages } from "./tool-images.js";
|
|
import type { WorkspaceBootstrapFile } from "./workspace.js";
|
|
|
|
export type EmbeddedContextFile = { path: string; content: string };
|
|
|
|
export async function ensureSessionHeader(params: {
|
|
sessionFile: string;
|
|
sessionId: string;
|
|
cwd: string;
|
|
}) {
|
|
const file = params.sessionFile;
|
|
try {
|
|
await fs.stat(file);
|
|
return;
|
|
} catch {
|
|
// create
|
|
}
|
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
const sessionVersion = 2;
|
|
const entry = {
|
|
type: "session",
|
|
version: sessionVersion,
|
|
id: params.sessionId,
|
|
timestamp: new Date().toISOString(),
|
|
cwd: params.cwd,
|
|
};
|
|
await fs.writeFile(file, `${JSON.stringify(entry)}\n`, "utf-8");
|
|
}
|
|
|
|
type ContentBlock = AgentToolResult<unknown>["content"][number];
|
|
|
|
export async function sanitizeSessionMessagesImages(
|
|
messages: AgentMessage[],
|
|
label: string,
|
|
): Promise<AgentMessage[]> {
|
|
// We sanitize historical session messages because Anthropic can reject a request
|
|
// if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX).
|
|
const out: AgentMessage[] = [];
|
|
for (const msg of messages) {
|
|
if (!msg || typeof msg !== "object") {
|
|
out.push(msg);
|
|
continue;
|
|
}
|
|
|
|
const role = (msg as { role?: unknown }).role;
|
|
if (role === "toolResult") {
|
|
const toolMsg = msg as Extract<AgentMessage, { role: "toolResult" }>;
|
|
const content = Array.isArray(toolMsg.content) ? toolMsg.content : [];
|
|
const nextContent = (await sanitizeContentBlocksImages(
|
|
content as ContentBlock[],
|
|
label,
|
|
)) as unknown as typeof toolMsg.content;
|
|
out.push({ ...toolMsg, content: nextContent });
|
|
continue;
|
|
}
|
|
|
|
if (role === "user") {
|
|
const userMsg = msg as Extract<AgentMessage, { role: "user" }>;
|
|
const content = userMsg.content;
|
|
if (Array.isArray(content)) {
|
|
const nextContent = (await sanitizeContentBlocksImages(
|
|
content as unknown as ContentBlock[],
|
|
label,
|
|
)) as unknown as typeof userMsg.content;
|
|
out.push({ ...userMsg, content: nextContent });
|
|
continue;
|
|
}
|
|
}
|
|
|
|
out.push(msg);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function buildBootstrapContextFiles(
|
|
files: WorkspaceBootstrapFile[],
|
|
): EmbeddedContextFile[] {
|
|
return files.map((file) => ({
|
|
path: file.name,
|
|
content: file.missing
|
|
? `[MISSING] Expected at: ${file.path}`
|
|
: (file.content ?? ""),
|
|
}));
|
|
}
|
|
|
|
export function formatAssistantErrorText(
|
|
msg: AssistantMessage,
|
|
): string | undefined {
|
|
if (msg.stopReason !== "error") return undefined;
|
|
const raw = (msg.errorMessage ?? "").trim();
|
|
if (!raw) return "LLM request failed with an unknown error.";
|
|
|
|
const invalidRequest = raw.match(
|
|
/"type":"invalid_request_error".*?"message":"([^"]+)"/,
|
|
);
|
|
if (invalidRequest?.[1]) {
|
|
return `LLM request rejected: ${invalidRequest[1]}`;
|
|
}
|
|
|
|
// Keep it short for WhatsApp.
|
|
return raw.length > 600 ? `${raw.slice(0, 600)}…` : raw;
|
|
}
|
|
|
|
export function isRateLimitAssistantError(
|
|
msg: AssistantMessage | undefined,
|
|
): boolean {
|
|
if (!msg || msg.stopReason !== "error") return false;
|
|
const raw = (msg.errorMessage ?? "").toLowerCase();
|
|
if (!raw) return false;
|
|
return (
|
|
/rate[_ ]limit|too many requests|429/.test(raw) ||
|
|
raw.includes("exceeded your current quota")
|
|
);
|
|
}
|
|
|
|
function extractSupportedValues(raw: string): string[] {
|
|
const match =
|
|
raw.match(/supported values are:\s*([^\n.]+)/i) ??
|
|
raw.match(/supported values:\s*([^\n.]+)/i);
|
|
if (!match?.[1]) return [];
|
|
const fragment = match[1];
|
|
const quoted = Array.from(fragment.matchAll(/['"]([^'"]+)['"]/g)).map(
|
|
(entry) => entry[1]?.trim(),
|
|
);
|
|
if (quoted.length > 0) {
|
|
return quoted.filter((entry): entry is string => Boolean(entry));
|
|
}
|
|
return fragment
|
|
.split(/,|\band\b/gi)
|
|
.map((entry) => entry.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, "").trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
export function pickFallbackThinkingLevel(params: {
|
|
message?: string;
|
|
attempted: Set<ThinkLevel>;
|
|
}): ThinkLevel | undefined {
|
|
const raw = params.message?.trim();
|
|
if (!raw) return undefined;
|
|
const supported = extractSupportedValues(raw);
|
|
if (supported.length === 0) return undefined;
|
|
for (const entry of supported) {
|
|
const normalized = normalizeThinkLevel(entry);
|
|
if (!normalized) continue;
|
|
if (params.attempted.has(normalized)) continue;
|
|
return normalized;
|
|
}
|
|
return undefined;
|
|
}
|