From ccfcfe4a387279911b0b0f5c21e8c7e75621e7a3 Mon Sep 17 00:00:00 2001 From: Eve Date: Thu, 23 Apr 2026 00:27:48 +0800 Subject: [PATCH] Strengthen long-task preflight enforcement language --- hooks/force-recall/handler.ts | 142 ++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/hooks/force-recall/handler.ts b/hooks/force-recall/handler.ts index e69de29..2e96d3c 100644 --- a/hooks/force-recall/handler.ts +++ b/hooks/force-recall/handler.ts @@ -0,0 +1,142 @@ +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); + +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 { + try { + const raw = await fs.readFile(filePath, "utf-8"); + const trimmed = raw.trim(); + return trimmed ? trimmed : null; + } catch { + return null; + } +} + +async function runLongTaskWrapper(workspaceDir: string, ctx: any): Promise { + let tempInputPath: string | null = null; + + try { + 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: "", + }; + + tempInputPath = path.join(os.tmpdir(), `openclaw-long-task-hook-${process.pid}-${Date.now()}.json`); + await fs.writeFile(tempInputPath, JSON.stringify(input), "utf-8"); + + const { stdout } = await execFileAsync("node", [wrapperPath, "--compact", "--input", tempInputPath], { + cwd: workspaceDir, + maxBuffer: 1024 * 1024, + timeout: 8000, + }); + + return JSON.parse(stdout); + } catch { + return null; + } finally { + if (tempInputPath) { + await fs.unlink(tempInputPath).catch(() => {}); + } + } +} + +/** + * 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), + ]); + + if (!rulebook && !soul && !wrapperResult) 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, + "- Treat this as preflight guidance from the wrapper MVP, not final truth.", + "- If classification=long_task, prefer task state + checkpoint discipline.", + "- If silentCandidate=true and silentLaunchOk=false, do not launch silent mode as-is.", + "[/LONG_TASK_GOVERNOR_PREFLIGHT]", + "", + ] + .filter(Boolean) + .join("\n") + : ""; + + 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, + 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;