mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 06:11:38 +00:00
* refactor(active-memory): split runtime modules * fix(active-memory): annotate hook deadline result * refactor(active-memory): keep runtime modules bounded * fix(active-memory): use type-only recall imports * refactor(active-memory): supersede partial split * fix(gateway): type pruned worker manifest
408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
/**
|
|
* Active Memory plugin entry. Runtime behavior lives in focused sibling modules.
|
|
*/
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
|
|
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
import {
|
|
hasDeprecatedModelFallbackPolicy,
|
|
isMissingRegisteredMemoryToolsError,
|
|
normalizePluginConfig,
|
|
resetActiveMemoryConfigForTests,
|
|
setMinimumTimeoutMsForTests,
|
|
setSetupGraceTimeoutMsForTests,
|
|
} from "./config.js";
|
|
import { buildMetadata, buildPromptPrefix } from "./prompt.js";
|
|
import { buildQuery, buildSearchQuery, extractRecentTurns } from "./query.js";
|
|
import {
|
|
buildCacheKey,
|
|
buildCircuitBreakerKey,
|
|
getCachedResult,
|
|
getCircuitBreakerEntry,
|
|
isCircuitBreakerOpen,
|
|
resetActiveRecallStateForTests,
|
|
setCachedResult,
|
|
shouldCacheResult,
|
|
toSingleLineLogValue,
|
|
} from "./recall-state.js";
|
|
import { maybeResolveActiveRecall } from "./recall.js";
|
|
import {
|
|
ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT,
|
|
formatActiveMemoryCommandHelp,
|
|
isActiveMemoryGloballyEnabled,
|
|
isAllowedChatId,
|
|
isAllowedChatType,
|
|
isEligibleInteractiveSession,
|
|
isEnabledForAgent,
|
|
isSessionActiveMemoryDisabled,
|
|
lacksAdminToMutateActiveMemoryGlobal,
|
|
resolveCommandSessionKey,
|
|
setSessionActiveMemoryDisabled,
|
|
shouldSkipActiveMemoryForHarnessSession,
|
|
updateActiveMemoryGlobalEnabledInConfig,
|
|
} from "./session-policy.js";
|
|
import {
|
|
buildPluginStatusLine,
|
|
persistPluginStatusLines,
|
|
resolveCanonicalSessionKeyFromSessionId,
|
|
resolveStatusUpdateAgentId,
|
|
} from "./session.js";
|
|
import {
|
|
readPartialAssistantText,
|
|
resetActiveMemoryTranscriptForTests,
|
|
setTimeoutPartialDataGraceMsForTests,
|
|
} from "./transcript-result.js";
|
|
import { readActiveMemorySearchDebug } from "./transcript-watch.js";
|
|
import {
|
|
createActiveMemoryHookDeadline,
|
|
hasUsableMemoryResultInSessionRecord,
|
|
} from "./transcript.js";
|
|
import {
|
|
HOOK_TIMEOUT_RECOVERY_GRACE_MS,
|
|
MAX_SETUP_GRACE_TIMEOUT_MS,
|
|
MAX_TIMEOUT_MS,
|
|
} from "./types.js";
|
|
|
|
/** Plugin entry registering Active Memory hooks, tools, config schema, and doctor cleanup. */
|
|
export default definePluginEntry({
|
|
id: "active-memory",
|
|
name: "Active Memory",
|
|
description: "Proactively surfaces relevant memory before eligible conversational replies.",
|
|
register(api: OpenClawPluginApi) {
|
|
const readCurrentConfig = (): OpenClawConfig | undefined => {
|
|
try {
|
|
return (
|
|
(api.runtime.config?.current?.() as OpenClawConfig | undefined) ??
|
|
(api.config as OpenClawConfig | undefined)
|
|
);
|
|
} catch {
|
|
return api.config as OpenClawConfig | undefined;
|
|
}
|
|
};
|
|
let config = normalizePluginConfig(api.pluginConfig, readCurrentConfig());
|
|
const warnDeprecatedModelFallbackPolicy = (pluginConfig: unknown) => {
|
|
if (hasDeprecatedModelFallbackPolicy(pluginConfig)) {
|
|
// Wording matters here: the previous text ("set config.modelFallback
|
|
// explicitly if you want a fallback model") read naturally as runtime
|
|
// failover (model A errors → switch to model B), but `getModelRef`
|
|
// only consults `modelFallback` as the *last candidate* in the
|
|
// resolution chain after `config.model`, the current run's model,
|
|
// and the agent's configured default have all resolved to nothing.
|
|
// Surface the chain-resolution semantics directly so operators
|
|
// don't waste debug cycles assuming runtime failover (#74587).
|
|
api.logger.warn?.(
|
|
"active-memory: config.modelFallbackPolicy is deprecated and no longer changes runtime behavior. " +
|
|
"config.modelFallback is a chain-resolution last-resort (consulted only when config.model, " +
|
|
"the current run's model, and the agent's configured default all resolve to nothing) — " +
|
|
"it is NOT a runtime failover that substitutes a different model when the resolved model errors out.",
|
|
);
|
|
}
|
|
};
|
|
warnDeprecatedModelFallbackPolicy(api.pluginConfig);
|
|
const refreshLiveConfigFromRuntime = () => {
|
|
const livePluginConfig = resolveLivePluginConfigObject(
|
|
api.runtime.config?.current
|
|
? () => api.runtime.config.current() as OpenClawConfig
|
|
: undefined,
|
|
"active-memory",
|
|
api.pluginConfig as Record<string, unknown>,
|
|
);
|
|
config = normalizePluginConfig(livePluginConfig ?? { enabled: false }, readCurrentConfig());
|
|
if (livePluginConfig) {
|
|
warnDeprecatedModelFallbackPolicy(livePluginConfig);
|
|
}
|
|
};
|
|
api.registerCommand({
|
|
name: "active-memory",
|
|
description: "Enable, disable, or inspect Active Memory for this session.",
|
|
acceptsArgs: true,
|
|
exposeSenderIsOwner: true,
|
|
handler: async (ctx) => {
|
|
const tokens = ctx.args?.trim().split(/\s+/).filter(Boolean) ?? [];
|
|
const isGlobal = tokens.includes("--global");
|
|
const action = (tokens.find((token) => token !== "--global") ?? "status").toLowerCase();
|
|
if (action === "help") {
|
|
return { text: formatActiveMemoryCommandHelp() };
|
|
}
|
|
if (isGlobal) {
|
|
const currentConfig = api.runtime.config.current() as OpenClawConfig;
|
|
if (action === "status") {
|
|
return {
|
|
text: `Active Memory: ${isActiveMemoryGloballyEnabled(currentConfig) ? "on" : "off"} globally.`,
|
|
};
|
|
}
|
|
if (
|
|
lacksAdminToMutateActiveMemoryGlobal({
|
|
senderIsOwner: ctx.senderIsOwner,
|
|
gatewayClientScopes: ctx.gatewayClientScopes,
|
|
})
|
|
) {
|
|
return {
|
|
text: ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT,
|
|
};
|
|
}
|
|
if (action === "on" || action === "enable" || action === "enabled") {
|
|
await api.runtime.config.mutateConfigFile({
|
|
afterWrite: { mode: "auto" },
|
|
mutate: (draft) => {
|
|
const nextConfig = updateActiveMemoryGlobalEnabledInConfig(draft, true);
|
|
Object.assign(draft, nextConfig);
|
|
},
|
|
});
|
|
refreshLiveConfigFromRuntime();
|
|
return { text: "Active Memory: on globally." };
|
|
}
|
|
if (action === "off" || action === "disable" || action === "disabled") {
|
|
await api.runtime.config.mutateConfigFile({
|
|
afterWrite: { mode: "auto" },
|
|
mutate: (draft) => {
|
|
const nextConfig = updateActiveMemoryGlobalEnabledInConfig(draft, false);
|
|
Object.assign(draft, nextConfig);
|
|
},
|
|
});
|
|
refreshLiveConfigFromRuntime();
|
|
return { text: "Active Memory: off globally." };
|
|
}
|
|
}
|
|
const sessionKey = resolveCommandSessionKey({
|
|
api,
|
|
config,
|
|
sessionKey: ctx.sessionKey,
|
|
sessionId: ctx.sessionId,
|
|
});
|
|
if (!sessionKey) {
|
|
return {
|
|
text: "Active Memory: session toggle unavailable because this command has no session context.",
|
|
};
|
|
}
|
|
const commandAgentId = resolveStatusUpdateAgentId({ sessionKey });
|
|
if (!isEnabledForAgent(config, commandAgentId)) {
|
|
return { text: "Active Memory: off for this session." };
|
|
}
|
|
if (action === "status") {
|
|
const disabled = await isSessionActiveMemoryDisabled({ api, sessionKey });
|
|
return {
|
|
text: `Active Memory: ${disabled ? "off" : "on"} for this session.`,
|
|
};
|
|
}
|
|
if (action === "on" || action === "enable" || action === "enabled") {
|
|
await setSessionActiveMemoryDisabled({ api, sessionKey, disabled: false });
|
|
return { text: "Active Memory: on for this session." };
|
|
}
|
|
if (action === "off" || action === "disable" || action === "disabled") {
|
|
await setSessionActiveMemoryDisabled({ api, sessionKey, disabled: true });
|
|
await persistPluginStatusLines({
|
|
api,
|
|
agentId: resolveStatusUpdateAgentId({ sessionKey }),
|
|
sessionKey,
|
|
});
|
|
return { text: "Active Memory: off for this session." };
|
|
}
|
|
return {
|
|
text: `Unknown Active Memory action: ${action}\n\n${formatActiveMemoryCommandHelp()}`,
|
|
};
|
|
},
|
|
});
|
|
|
|
// Preflight and recall own separate deadlines. Reserve enough hook time for
|
|
// both maxima so preflight latency cannot consume recall settlement time.
|
|
const beforePromptBuildTimeoutMs =
|
|
MAX_TIMEOUT_MS + MAX_SETUP_GRACE_TIMEOUT_MS + HOOK_TIMEOUT_RECOVERY_GRACE_MS * 2;
|
|
api.on(
|
|
"before_prompt_build",
|
|
async (event, ctx) => {
|
|
refreshLiveConfigFromRuntime();
|
|
const invocationConfig = config;
|
|
const liveRecallTimeoutMs =
|
|
invocationConfig.timeoutMs +
|
|
invocationConfig.setupGraceTimeoutMs +
|
|
HOOK_TIMEOUT_RECOVERY_GRACE_MS;
|
|
const deadlineController = new AbortController();
|
|
const hookDeadline = createActiveMemoryHookDeadline();
|
|
const armHookDeadline = (timeoutMs: number, phase: "preflight" | "recall") => {
|
|
hookDeadline.arm(timeoutMs, () => {
|
|
deadlineController.abort(
|
|
new Error(`active-memory ${phase} timeout after ${timeoutMs}ms`),
|
|
);
|
|
api.logger.warn?.(
|
|
`active-memory: before_prompt_build ${phase} timed out after ${String(timeoutMs)}ms; skipping memory lookup`,
|
|
);
|
|
});
|
|
};
|
|
armHookDeadline(HOOK_TIMEOUT_RECOVERY_GRACE_MS, "preflight");
|
|
const handlerPromise = (async () => {
|
|
try {
|
|
const resolvedAgentId = resolveStatusUpdateAgentId(ctx);
|
|
const resolvedSessionKey =
|
|
ctx.sessionKey?.trim() ||
|
|
(resolvedAgentId
|
|
? resolveCanonicalSessionKeyFromSessionId({
|
|
api,
|
|
agentId: resolvedAgentId,
|
|
sessionId: ctx.sessionId,
|
|
})
|
|
: undefined);
|
|
const effectiveAgentId =
|
|
resolvedAgentId || resolveStatusUpdateAgentId({ sessionKey: resolvedSessionKey });
|
|
if (
|
|
shouldSkipActiveMemoryForHarnessSession({
|
|
api,
|
|
agentId: effectiveAgentId,
|
|
sessionKey: resolvedSessionKey,
|
|
})
|
|
) {
|
|
return undefined;
|
|
}
|
|
const sessionDisabled = await isSessionActiveMemoryDisabled({
|
|
api,
|
|
sessionKey: resolvedSessionKey,
|
|
});
|
|
deadlineController.signal.throwIfAborted();
|
|
if (sessionDisabled) {
|
|
await persistPluginStatusLines({
|
|
api,
|
|
agentId: effectiveAgentId,
|
|
sessionKey: resolvedSessionKey,
|
|
});
|
|
return undefined;
|
|
}
|
|
if (!isEnabledForAgent(invocationConfig, effectiveAgentId)) {
|
|
await persistPluginStatusLines({
|
|
api,
|
|
agentId: effectiveAgentId,
|
|
sessionKey: resolvedSessionKey,
|
|
});
|
|
return undefined;
|
|
}
|
|
if (
|
|
!isEligibleInteractiveSession({
|
|
...ctx,
|
|
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
|
|
})
|
|
) {
|
|
await persistPluginStatusLines({
|
|
api,
|
|
agentId: effectiveAgentId,
|
|
sessionKey: resolvedSessionKey,
|
|
});
|
|
return undefined;
|
|
}
|
|
if (
|
|
!isAllowedChatType(invocationConfig, {
|
|
...ctx,
|
|
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
|
|
mainKey: api.config.session?.mainKey,
|
|
})
|
|
) {
|
|
await persistPluginStatusLines({
|
|
api,
|
|
agentId: effectiveAgentId,
|
|
sessionKey: resolvedSessionKey,
|
|
});
|
|
return undefined;
|
|
}
|
|
if (
|
|
!isAllowedChatId(invocationConfig, {
|
|
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
|
|
messageProvider: ctx.messageProvider,
|
|
})
|
|
) {
|
|
await persistPluginStatusLines({
|
|
api,
|
|
agentId: effectiveAgentId,
|
|
sessionKey: resolvedSessionKey,
|
|
});
|
|
return undefined;
|
|
}
|
|
const recentTurns = extractRecentTurns(event.messages);
|
|
const query = buildQuery({
|
|
latestUserMessage: event.prompt,
|
|
recentTurns,
|
|
config: invocationConfig,
|
|
});
|
|
const searchQuery = buildSearchQuery({
|
|
latestUserMessage: event.prompt,
|
|
recentTurns,
|
|
});
|
|
// Start recall with its full configured budget. The preceding
|
|
// session/config checks must not consume abort-settlement time.
|
|
armHookDeadline(liveRecallTimeoutMs, "recall");
|
|
const result = await maybeResolveActiveRecall({
|
|
api,
|
|
config: invocationConfig,
|
|
agentId: effectiveAgentId,
|
|
sessionKey: resolvedSessionKey,
|
|
sessionId: ctx.sessionId,
|
|
messageProvider: ctx.messageProvider,
|
|
channelId: ctx.channelId,
|
|
query,
|
|
searchQuery,
|
|
currentModelProviderId: ctx.modelProviderId,
|
|
currentModelId: ctx.modelId,
|
|
abortSignal: deadlineController.signal,
|
|
});
|
|
deadlineController.signal.throwIfAborted();
|
|
if (!result.summary) {
|
|
return undefined;
|
|
}
|
|
const promptPrefix = buildPromptPrefix(result.summary);
|
|
if (!promptPrefix) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
prependContext: promptPrefix,
|
|
};
|
|
} catch (error) {
|
|
if (deadlineController.signal.aborted) {
|
|
return undefined;
|
|
}
|
|
const message = toSingleLineLogValue(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
api.logger.warn?.(
|
|
`active-memory: before_prompt_build failed, skipping memory lookup: ${message}`,
|
|
);
|
|
return undefined;
|
|
}
|
|
})();
|
|
try {
|
|
const result = await Promise.race([handlerPromise, hookDeadline.promise]);
|
|
return typeof result === "symbol" ? undefined : result;
|
|
} finally {
|
|
hookDeadline.stop();
|
|
}
|
|
},
|
|
{ timeoutMs: beforePromptBuildTimeoutMs },
|
|
);
|
|
},
|
|
});
|
|
|
|
const testing = {
|
|
buildSearchQuery,
|
|
buildCacheKey,
|
|
buildCircuitBreakerKey,
|
|
buildMetadata,
|
|
buildPluginStatusLine,
|
|
buildPromptPrefix,
|
|
getCachedResult,
|
|
hasUsableMemoryResultInSessionRecord,
|
|
isCircuitBreakerOpen,
|
|
isMissingRegisteredMemoryToolsError,
|
|
normalizePluginConfig,
|
|
readActiveMemorySearchDebug,
|
|
readPartialAssistantText,
|
|
shouldCacheResult,
|
|
resetActiveRecallCacheForTests() {
|
|
resetActiveRecallStateForTests();
|
|
resetActiveMemoryConfigForTests();
|
|
resetActiveMemoryTranscriptForTests();
|
|
},
|
|
setMinimumTimeoutMsForTests,
|
|
setSetupGraceTimeoutMsForTests,
|
|
setTimeoutPartialDataGraceMsForTests,
|
|
setCachedResult,
|
|
getCircuitBreakerEntry,
|
|
};
|
|
|
|
export { testing, testing as __testing };
|