509 lines
22 KiB
TypeScript
509 lines
22 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;
|
|
const LONG_TASK_AUTO_CHAIN_PLANNER_TIMEOUT_MS = 8000;
|
|
|
|
type AutoChainPlanResult = {
|
|
plannerStatus: string;
|
|
derivedAction: string;
|
|
dispatchMode: string;
|
|
reason: string;
|
|
requiredEvidence?: string[];
|
|
autoChainAllowed: boolean;
|
|
};
|
|
|
|
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 buildProgressEvidence(wrapperResult: any): Record<string, unknown> | null {
|
|
const candidate = wrapperResult?.progressEvidence;
|
|
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
return null;
|
|
}
|
|
|
|
const progressEvidence: Record<string, unknown> = {};
|
|
|
|
const sessionKey = typeof candidate.sessionKey === "string"
|
|
? candidate.sessionKey.trim()
|
|
: "";
|
|
if (sessionKey) {
|
|
progressEvidence.sessionKey = sessionKey;
|
|
}
|
|
|
|
const runId = typeof candidate.runId === "string"
|
|
? candidate.runId.trim()
|
|
: "";
|
|
if (runId) {
|
|
progressEvidence.runId = runId;
|
|
}
|
|
|
|
if (Array.isArray(candidate.modified_files) && candidate.modified_files.length > 0) {
|
|
progressEvidence.modified_files = candidate.modified_files;
|
|
}
|
|
|
|
const verificationResult = typeof candidate.verificationResult === "string"
|
|
? candidate.verificationResult.trim()
|
|
: "";
|
|
if (verificationResult) {
|
|
progressEvidence.verificationResult = verificationResult;
|
|
}
|
|
|
|
return Object.keys(progressEvidence).length > 0 ? progressEvidence : null;
|
|
}
|
|
|
|
function shouldClaimProgression(wrapperResult: any, progressEvidence: Record<string, unknown> | null): boolean {
|
|
if (!wrapperResult || wrapperResult.classification !== "long_task") return false;
|
|
if (progressEvidence && Object.keys(progressEvidence).length > 0) return true;
|
|
|
|
const requiredNextAction = typeof wrapperResult.requiredNextAction === "string"
|
|
? wrapperResult.requiredNextAction.trim()
|
|
: "";
|
|
const progressingActionPrefixes = [
|
|
"dispatch_",
|
|
"handoff_",
|
|
"launch_",
|
|
"resume_",
|
|
"continue_",
|
|
"queue_",
|
|
"schedule_",
|
|
"run_",
|
|
"start_",
|
|
"spawn_",
|
|
];
|
|
|
|
if (requiredNextAction && progressingActionPrefixes.some((prefix) => requiredNextAction.startsWith(prefix))) {
|
|
return true;
|
|
}
|
|
|
|
return wrapperResult.silentLaunchOk === true;
|
|
}
|
|
|
|
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 progressEvidence = buildProgressEvidence(wrapperResult);
|
|
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 autoChainNextAction = hasConcreteExecutionEvidence ? requiredNextAction : "";
|
|
const executionEvidence = hasConcreteExecutionEvidence
|
|
? {
|
|
concreteNextAction: requiredNextAction,
|
|
}
|
|
: null;
|
|
const autoChainDispatchEvidence = hasConcreteExecutionEvidence
|
|
&& wrapperResult.autoChainDispatchEvidence
|
|
&& typeof wrapperResult.autoChainDispatchEvidence === "object"
|
|
&& !Array.isArray(wrapperResult.autoChainDispatchEvidence)
|
|
? wrapperResult.autoChainDispatchEvidence
|
|
: null;
|
|
const claimedProgression = shouldClaimProgression(wrapperResult, progressEvidence)
|
|
? "already progressing to the next step in background"
|
|
: "";
|
|
const progressEvidenceReason = claimedProgression && !progressEvidence
|
|
? "progression claim requires concrete evidence such as sessionKey, runId, modified_files, or verification result"
|
|
: "";
|
|
const hasExternalizedCheckpointEvidence = typeof wrapperResult.externalizedCheckpointPath === "string"
|
|
&& wrapperResult.externalizedCheckpointPath.trim().length > 0;
|
|
const hasButtonPathClosureEvidence = needsOwnerDecision && wrapperResult.silentLaunchOk === true;
|
|
|
|
return {
|
|
classification: wrapperResult.classification,
|
|
silentContinuation: silentCandidate,
|
|
claimedExecution: hasConcreteExecutionEvidence || (silentCandidate && wrapperResult.silentLaunchOk !== true),
|
|
needsOwnerDecision,
|
|
nextStep: hasConcreteExecutionEvidence ? requiredNextAction : "",
|
|
requiredNextAction: hasConcreteExecutionEvidence ? requiredNextAction : "",
|
|
concreteNextAction: hasConcreteExecutionEvidence ? requiredNextAction : "",
|
|
autoChainNextAction,
|
|
autoChainDispatchEvidence,
|
|
progressionClaim: claimedProgression,
|
|
claimedProgression: claimedProgression,
|
|
statusSummary: claimedProgression,
|
|
executionEvidence,
|
|
progressEvidence,
|
|
autoChainDispatchEvidenceReason: hasConcreteExecutionEvidence && !autoChainDispatchEvidence
|
|
? "explicit auto-chain next action requires dispatched-action evidence"
|
|
: "",
|
|
progressEvidenceReason,
|
|
sessionKey: typeof progressEvidence?.sessionKey === "string" ? progressEvidence.sessionKey : "",
|
|
runId: typeof progressEvidence?.runId === "string" ? progressEvidence.runId : "",
|
|
modified_files: Array.isArray(progressEvidence?.modified_files) ? progressEvidence.modified_files : [],
|
|
verificationResult: typeof progressEvidence?.verificationResult === "string" ? progressEvidence.verificationResult : "",
|
|
toolCallEvidence: "",
|
|
dispatchEvidence: "",
|
|
fileChangeEvidence: "",
|
|
verificationEvidence: "",
|
|
checkpointArtifactEvidence: hasExternalizedCheckpointEvidence ? wrapperResult.externalizedCheckpointPath.trim() : "",
|
|
externalizedCheckpointPath: hasExternalizedCheckpointEvidence ? wrapperResult.externalizedCheckpointPath.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 buildAutoChainPlannerInput(gateLockResult: GateLockResult | null, wrapperResult: any): Record<string, unknown> {
|
|
const requiredNextAction = typeof wrapperResult?.requiredNextAction === "string"
|
|
? wrapperResult.requiredNextAction.trim()
|
|
: "";
|
|
const plannerInput: Record<string, unknown> = {
|
|
gateStatus: gateLockResult?.gateStatus ?? "not_applicable",
|
|
actorStage: "hook_preflight",
|
|
requiredNextAction,
|
|
};
|
|
|
|
if (!requiredNextAction) return plannerInput;
|
|
|
|
if (requiredNextAction === "dispatch_follow_up_subagent") {
|
|
plannerInput.actorStage = "implementer_result";
|
|
plannerInput.requiredNextAction = "request_spec_review";
|
|
if (wrapperResult?.autoChainDispatchEvidence && typeof wrapperResult.autoChainDispatchEvidence === "object" && !Array.isArray(wrapperResult.autoChainDispatchEvidence)) {
|
|
plannerInput.executionEvidence = wrapperResult.autoChainDispatchEvidence;
|
|
}
|
|
return plannerInput;
|
|
}
|
|
|
|
if (requiredNextAction === "dispatch_code_quality_review") {
|
|
plannerInput.actorStage = "spec_review";
|
|
plannerInput.requiredNextAction = "request_code_quality_review";
|
|
plannerInput.reviewOutcome = "pass";
|
|
if (wrapperResult?.reviewEvidence && typeof wrapperResult.reviewEvidence === "object" && !Array.isArray(wrapperResult.reviewEvidence)) {
|
|
plannerInput.reviewEvidence = wrapperResult.reviewEvidence;
|
|
}
|
|
return plannerInput;
|
|
}
|
|
|
|
if (requiredNextAction === "dispatch_fix_slice") {
|
|
plannerInput.actorStage = "review_result";
|
|
plannerInput.requiredNextAction = "fix_review_findings";
|
|
plannerInput.blocker = typeof wrapperResult?.silentLaunchReason === "string" && wrapperResult.silentLaunchReason.trim()
|
|
? wrapperResult.silentLaunchReason.trim()
|
|
: "hook_preflight_blocker";
|
|
if (wrapperResult?.blockerEvidence && typeof wrapperResult.blockerEvidence === "object" && !Array.isArray(wrapperResult.blockerEvidence)) {
|
|
plannerInput.blockerEvidence = wrapperResult.blockerEvidence;
|
|
}
|
|
return plannerInput;
|
|
}
|
|
|
|
if (requiredNextAction === "dispatch_spec_review") {
|
|
plannerInput.actorStage = "implementer_result";
|
|
plannerInput.requiredNextAction = "request_spec_review";
|
|
if (wrapperResult?.implementationEvidence && typeof wrapperResult.implementationEvidence === "object" && !Array.isArray(wrapperResult.implementationEvidence)) {
|
|
plannerInput.executionEvidence = wrapperResult.implementationEvidence;
|
|
}
|
|
return plannerInput;
|
|
}
|
|
|
|
return plannerInput;
|
|
}
|
|
|
|
async function runAutoChainPlanner(workspaceDir: string, gateLockResult: GateLockResult | null, wrapperResult: any): Promise<AutoChainPlanResult | null> {
|
|
if (!wrapperResult || wrapperResult.classification !== "long_task") return null;
|
|
const plannerPath = path.join(workspaceDir, "scripts", "plan_long_task_auto_chain.mjs");
|
|
const input = buildAutoChainPlannerInput(gateLockResult, wrapperResult);
|
|
return runJsonScript(plannerPath, workspaceDir, input, LONG_TASK_AUTO_CHAIN_PLANNER_TIMEOUT_MS);
|
|
}
|
|
|
|
function buildAutoChainPlanBlock(planResult: AutoChainPlanResult | null): string {
|
|
if (!planResult) {
|
|
return [
|
|
"[LONG_TASK_AUTO_CHAIN_PLAN]",
|
|
"plannerStatus=degraded",
|
|
"derivedAction=none",
|
|
"dispatchMode=no_dispatch",
|
|
"autoChainAllowed=false",
|
|
"reason=auto-chain planner unavailable during hook preflight",
|
|
"[/LONG_TASK_AUTO_CHAIN_PLAN]",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
return [
|
|
"[LONG_TASK_AUTO_CHAIN_PLAN]",
|
|
`plannerStatus=${planResult.plannerStatus}`,
|
|
`derivedAction=${planResult.derivedAction}`,
|
|
`dispatchMode=${planResult.dispatchMode}`,
|
|
`autoChainAllowed=${planResult.autoChainAllowed}`,
|
|
`reason=${planResult.reason}`,
|
|
...((planResult.requiredEvidence ?? []).map((entry) => `requiredEvidence=${entry}`)),
|
|
"[/LONG_TASK_AUTO_CHAIN_PLAN]",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
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 progress evidence such as a sessionKey, runId, modified_files record, verification result, actual dispatch, tool calls, file changes, or a persisted checkpoint artifact.",
|
|
"- ENFORCEMENT: Hook inputs for any progression claim should carry progressEvidence (or equivalent concrete fields) so the gate can verify the claim.",
|
|
"- HARD_GATE: Evaluator unavailable is not permission to claim silent continuation or next-task progression without verifiable progress 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 progress evidence such as a sessionKey, runId, modified_files record, verification result, actual dispatch, tool calls, file changes, or a persisted checkpoint artifact.",
|
|
"- ENFORCEMENT: Hook input should include progressEvidence (or equivalent concrete fields) whenever a progression claim is present.",
|
|
"- 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.",
|
|
"- ENFORCEMENT: If hook input carries autoChainNextAction, it must also carry matching autoChainDispatchEvidence before the gate may pass that auto-chain step.",
|
|
];
|
|
|
|
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 progress evidence.");
|
|
lines.push("- HARD_GATE: If a progression claim exists, the hook input must supply progressEvidence (or equivalent concrete fields) before the claim can pass gate.");
|
|
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 a sessionKey, runId, modified_files record, verification result, 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 autoChainNextAction is explicit, you must actually dispatch it and surface autoChainDispatchEvidence; otherwise the gate fails.");
|
|
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;
|
|
const autoChainPlanResult = wrapperResult ? await runAutoChainPlanner(workspaceDir, gateLockResult, wrapperResult) : null;
|
|
|
|
if (!rulebook && !soul && !wrapperResult && !gateLockResult && !autoChainPlanResult) 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 autoChainPlanBlock = buildAutoChainPlanBlock(autoChainPlanResult);
|
|
|
|
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,
|
|
autoChainPlanBlock,
|
|
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;
|