Files
reporting-governance-plugin/hooks/force-recall/handler.ts

301 lines
13 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const LONG_TASK_WRAPPER_TIMEOUT_MS = 8000;
const LONG_TASK_GATE_LOCK_TIMEOUT_MS = 8000;
type GateLockResult = {
gateRequired: boolean;
gateStatus: "not_applicable" | "pass" | "fail";
reasons?: string[];
requiredEvidence?: Array<{
evidenceKey?: string;
acceptedFields?: string[];
requiredValue?: string;
}>;
allowedResponseModes?: string[];
};
function clamp(s: string, max = 1200): string {
if (!s) return s;
if (s.length <= max) return s;
return s.slice(0, max) + "\n…(truncated)…";
}
async function safeReadText(filePath: string): Promise<string | null> {
try {
const raw = await fs.readFile(filePath, "utf-8");
const trimmed = raw.trim();
return trimmed ? trimmed : null;
} catch {
return null;
}
}
async function runJsonScript(scriptPath: string, workspaceDir: string, input: Record<string, unknown>, timeout: number): Promise<any | null> {
let tempInputPath: string | null = null;
try {
tempInputPath = path.join(
os.tmpdir(),
`openclaw-hook-${path.basename(scriptPath, path.extname(scriptPath))}-${process.pid}-${Date.now()}.json`,
);
await fs.writeFile(tempInputPath, JSON.stringify(input), "utf-8");
const { stdout } = await execFileAsync("node", [scriptPath, "--compact", "--input", tempInputPath], {
cwd: workspaceDir,
maxBuffer: 1024 * 1024,
timeout,
});
return JSON.parse(stdout);
} catch {
return null;
} finally {
if (tempInputPath) {
await fs.unlink(tempInputPath).catch(() => {});
}
}
}
async function runLongTaskWrapper(workspaceDir: string, ctx: any): Promise<any | null> {
const wrapperPath = path.join(workspaceDir, "scripts", "long_task_governor_wrapper.mjs");
const input = {
requestText: (ctx.body ?? ctx.content ?? ctx.bodyForAgent ?? "") as string,
hasFilesOrSystems: false,
needsWaiting: false,
needsSubagent: false,
needsOwnerDecision: false,
canReplyNow: false,
taskName: "Hook preflight classification",
currentStep: "Classifying request at preprocessed hook",
nextStep: "Carry governor recommendation into prompt context",
nextReportCondition: "At next meaningful milestone",
waitingOn: "none",
blocker: "none",
checkpointTrigger: "",
externalizedTrigger: "",
triggerKind: "",
};
return runJsonScript(wrapperPath, workspaceDir, input, LONG_TASK_WRAPPER_TIMEOUT_MS);
}
function buildGateLockInput(wrapperResult: any): Record<string, unknown> {
if (!wrapperResult || wrapperResult.classification !== "long_task") {
return { classification: wrapperResult?.classification ?? "general_chat" };
}
const needsOwnerDecision = wrapperResult.needsOwnerDecision === true;
const silentCandidate = wrapperResult.silentCandidate === true;
const requiredNextAction = typeof wrapperResult.requiredNextAction === "string"
? wrapperResult.requiredNextAction.trim()
: "";
const hasConcreteExecutionEvidence = Boolean(
requiredNextAction
&& ![
"",
"proceed_with_normal_long_task_flow",
"proceed_with_silent_launch",
"define_first_checkpoint_trigger_before_silent_launch",
"bind_externalized_checkpoint_path_or_abort_silent_launch",
].includes(requiredNextAction),
);
const hasExternalizedCheckpointEvidence = wrapperResult.silentLaunchOk === true
&& typeof wrapperResult.taskRecord?.task_name === "string"
&& wrapperResult.taskRecord.task_name.trim().length > 0;
const hasButtonPathClosureEvidence = needsOwnerDecision && wrapperResult.silentLaunchOk === true;
return {
classification: wrapperResult.classification,
silentContinuation: silentCandidate,
claimedExecution: true,
needsOwnerDecision,
nextStep: hasConcreteExecutionEvidence ? requiredNextAction : "",
requiredNextAction: hasConcreteExecutionEvidence ? requiredNextAction : "",
concreteNextAction: hasConcreteExecutionEvidence ? requiredNextAction : "",
externalizedCheckpointPath: hasExternalizedCheckpointEvidence ? wrapperResult.taskRecord.task_name.trim() : "",
externalizedTrigger: hasExternalizedCheckpointEvidence ? "hook-preflight-checkpoint" : "",
handoffMode: hasButtonPathClosureEvidence ? (wrapperResult.handoff?.mode ?? "button_path") : "direct_reply",
replyClosureMode: hasButtonPathClosureEvidence ? (wrapperResult.handoff?.mode ?? "button_path") : "direct_reply",
};
}
async function runLongTaskGateLock(workspaceDir: string, wrapperResult: any): Promise<GateLockResult | null> {
const gateLockPath = path.join(workspaceDir, "scripts", "long_task_gate_lock.mjs");
const input = buildGateLockInput(wrapperResult);
return runJsonScript(gateLockPath, workspaceDir, input, LONG_TASK_GATE_LOCK_TIMEOUT_MS);
}
function buildWrapperEnforcement(wrapperResult: any): string[] {
const lines = [
"- Treat this as ingress preflight guidance from the wrapper MVP.",
];
if (wrapperResult.classification === "long_task") {
lines.push("- ENFORCEMENT: This request defaults to long-task governance; do not treat it as ordinary single-turn chat unless you can clearly justify overriding the classifier.");
lines.push("- ENFORCEMENT: If you proceed, prefer explicit task state and checkpoint discipline over ad-hoc continuation.");
}
if (wrapperResult.handoff?.mode === "button_path") {
lines.push("- ENFORCEMENT: Owner decision is expected; plan Telegram button-path early instead of ending with a plain-text menu.");
}
if (wrapperResult.silentCandidate === true && wrapperResult.silentLaunchOk === false) {
lines.push("- ENFORCEMENT: Silent launch is NOT allowed in the current form.");
lines.push("- ENFORCEMENT: Use the recommended fallback before proceeding.");
if (wrapperResult.requiredNextAction) {
lines.push(`- ENFORCEMENT: Required next action = ${wrapperResult.requiredNextAction}`);
}
} else if (wrapperResult.silentCandidate === true && wrapperResult.silentLaunchOk === true) {
lines.push("- ENFORCEMENT: Silent launch is only acceptable if you preserve externalized checkpoint discipline and do not rely on memory alone.");
}
return lines;
}
function buildWrapperHardGate(wrapperResult: any): string[] {
const lines: string[] = [];
if (wrapperResult.classification === "long_task") {
lines.push("- HARD_GATE: If you intend to proceed as ordinary chat, you must explicitly justify why long-task governance does not apply.");
}
if (wrapperResult.handoff?.mode === "button_path") {
lines.push("- HARD_GATE: Do not end this flow with a plain-text choice menu. Use Telegram inline buttons or execute the most reasonable next step directly.");
}
if (wrapperResult.silentCandidate === true && wrapperResult.silentLaunchOk === false) {
lines.push("- HARD_GATE: Do NOT launch or continue this task in silent mode in its current form.");
lines.push("- HARD_GATE: Before any silent execution, satisfy the required next action or downgrade to non-silent follow-up.");
}
return lines;
}
function buildGateLockBlock(gateLockResult: GateLockResult | null): string {
if (!gateLockResult) {
return [
"[LONG_TASK_GATE_LOCK]",
"gateStatus=degraded",
"gateRequired=unknown",
"- ENFORCEMENT: Gate-lock evaluator unavailable; keep existing long-task safeguards in force.",
"- ENFORCEMENT: Do not claim you have progressed into the next task or are already pushing the next step unless you have concrete evidence such as actual dispatch, tool calls, file changes, or a persisted checkpoint artifact.",
"- HARD_GATE: Evaluator unavailable is not permission to claim silent continuation or next-task progression without verifiable evidence.",
"- HARD_GATE: Fall back to a non-silent, evidence-preserving follow-up if you cannot prove checkpoint state or concrete execution.",
"[/LONG_TASK_GATE_LOCK]",
"",
].join("\n");
}
const lines = [
"[LONG_TASK_GATE_LOCK]",
`gateRequired=${gateLockResult.gateRequired}`,
`gateStatus=${gateLockResult.gateStatus}`,
...(gateLockResult.reasons ?? []).map((reason) => `reason=${reason}`),
...((gateLockResult.requiredEvidence ?? []).map((requirement) => {
const fields = (requirement.acceptedFields ?? []).join(",");
return `requiredEvidence=${requirement.evidenceKey ?? "unknown"};fields=${fields};requiredValue=${requirement.requiredValue ?? "unknown"}`;
})),
...((gateLockResult.allowedResponseModes ?? []).map((mode) => `allowedResponseMode=${mode}`)),
"- ENFORCEMENT: Do not claim you have progressed into the next task or are already pushing the next step unless you have concrete evidence such as actual dispatch, tool calls, file changes, or a persisted checkpoint artifact.",
"- ENFORCEMENT: Forbidden path: plain-text handoff that pretends the long task is already continuing without an externalized checkpoint.",
"- ENFORCEMENT: Forbidden path: stating you have already entered the next task/step when the record only contains planning language and no concrete execution evidence.",
];
if (gateLockResult.gateStatus === "fail") {
lines.push("- HARD_GATE: Block any plain-text handoff or silent-continuation claim when externalized checkpoint evidence is missing.");
lines.push("- HARD_GATE: Block any reply path that says you already moved into the next task or are advancing the next step without concrete execution evidence.");
lines.push("- HARD_GATE: Do not say you are already on the next task, already dispatched follow-up work, or already progressing in background unless you can point to actual tool execution, file changes, emitted messages, or checkpoint records.");
lines.push("- HARD_GATE: If required evidence is missing, ask for/produce the checkpoint or downgrade to a non-silent, evidence-preserving follow-up.");
lines.push("- HARD_GATE: If owner decision is involved, do not replace button-path closure with plain-text handoff.");
}
lines.push("[/LONG_TASK_GATE_LOCK]", "");
return lines.join("\n");
}
/**
* Force Recall hook handler
*
* Event: message:preprocessed
* - Reads docs/RULEBOOK.md and SOUL.md from the resolved workspace
* - Prepends a recall gate block to context.bodyForAgent
* - Optionally injects wrapper MVP classification hints when available
*/
const forceRecall = async (event: any) => {
if (event?.type !== "message" || event?.action !== "preprocessed") return;
const ctx = event.context ?? {};
const workspaceDir: string | undefined = ctx.workspaceDir;
if (!workspaceDir) return;
const rulebookPath = path.join(workspaceDir, "docs", "RULEBOOK.md");
const soulPath = path.join(workspaceDir, "SOUL.md");
const [rulebook, soul, wrapperResult] = await Promise.all([
safeReadText(rulebookPath),
safeReadText(soulPath),
runLongTaskWrapper(workspaceDir, ctx),
]);
const gateLockResult = wrapperResult ? await runLongTaskGateLock(workspaceDir, wrapperResult) : null;
if (!rulebook && !soul && !wrapperResult && !gateLockResult) return;
const wrapperBlock = wrapperResult
? [
"[LONG_TASK_GOVERNOR_PREFLIGHT]",
`classification=${wrapperResult.classification}`,
`silentCandidate=${wrapperResult.silentCandidate}`,
`needsCheckpoint=${wrapperResult.needsCheckpoint}`,
`needsSubagent=${wrapperResult.needsSubagent}`,
`needsOwnerDecision=${wrapperResult.needsOwnerDecision}`,
`silentLaunchOk=${wrapperResult.silentLaunchOk}`,
wrapperResult.silentLaunchReason ? `silentLaunchReason=${wrapperResult.silentLaunchReason}` : null,
wrapperResult.recommendedFallback ? `recommendedFallback=${wrapperResult.recommendedFallback}` : null,
wrapperResult.requiredNextAction ? `requiredNextAction=${wrapperResult.requiredNextAction}` : null,
wrapperResult.handoff?.mode ? `handoff.mode=${wrapperResult.handoff.mode}` : null,
...buildWrapperEnforcement(wrapperResult),
...buildWrapperHardGate(wrapperResult),
"[/LONG_TASK_GOVERNOR_PREFLIGHT]",
"",
]
.filter(Boolean)
.join("\n")
: "";
const gateLockBlock = buildGateLockBlock(gateLockResult);
const recallBlock = [
"[RECALL_GATE] Mandatory recall before ANY technical action/tool use.",
"- You MUST consult and follow the key rules from RULEBOOK + SOUL.",
"- If you are about to run tools, change configs, modify code, or delegate agents: restate the applicable rules first.",
"",
wrapperBlock || null,
gateLockBlock,
rulebook ? `RULEBOOK (source: ${rulebookPath}):\n${clamp(rulebook, 1200)}` : null,
soul ? `SOUL (source: ${soulPath}):\n${clamp(soul, 1200)}` : null,
"[/RECALL_GATE]",
"",
]
.filter(Boolean)
.join("\n");
const prior = (ctx.bodyForAgent ?? ctx.body ?? ctx.content ?? "") as string;
const injected = `${recallBlock}${prior ? "\n" + prior : ""}`;
ctx.bodyForAgent = injected;
event.context = ctx;
if (process.env.OPENCLAW_FORCE_RECALL_DEBUG === "1") {
ctx.bodyForAgent += "\n\n[force-recall:debug] injected";
console.log(`[force-recall:debug] injected for chat=${ctx.chatId ?? "?"} msg=${ctx.messageId ?? "?"}`);
}
};
export default forceRecall;