feat(active-memory): add recall-specific fast mode (#108043)

* feat(active-memory): add fast mode override

* fix(agents): preserve fast auto cutoff

* fix(active-memory): inherit parent fast mode

* docs: refresh generated docs map
This commit is contained in:
Peter Steinberger
2026-07-14 23:09:04 -07:00
committed by GitHub
parent 34b8a3fda0
commit 3d4ba02574
10 changed files with 213 additions and 4 deletions
+11
View File
@@ -67,6 +67,7 @@ What the key fields do:
- `config.allowedChatTypes: ["direct"]` scopes it to direct-message sessions (opt in groups/channels explicitly)
- `config.model` (optional) pins a dedicated recall model; unset inherits the current session model
- `config.modelFallback` is used only when no explicit or inherited model resolves
- `config.fastMode` optionally overrides fast mode for recall without changing the main agent
- `config.promptStyle: "balanced"` is the default for `recent` mode
- active memory still runs only for eligible interactive persistent chat sessions (see [When it runs](#when-it-runs))
@@ -504,6 +505,15 @@ adds user-visible latency):
thinking: "medium"; // default: "off"
```
`config.fastMode` overrides fast mode only for the blocking memory sub-agent.
Use `true`, `false`, or `"auto"`; leave it unset to inherit the normal
agent, session, and model defaults. `"auto"` uses the recall model's configured
`fastAutoOnSeconds` cutoff:
```json5
fastMode: true;
```
`config.promptAppend` adds operator instructions after the default prompt
and before the conversation context — pair it with a custom `toolsAllow` when
a non-core memory plugin needs specific tool order or query shaping:
@@ -574,6 +584,7 @@ All active memory configuration lives under `plugins.entries.active-memory`.
| `config.promptStyle` | `"balanced" \| "strict" \| "contextual" \| "recall-heavy" \| "precision-heavy" \| "preference-only"` | Controls how eager or strict the blocking sub-agent is when deciding whether to return memory |
| `config.toolsAllow` | `string[]` | Concrete memory tool names the blocking sub-agent may call; defaults to `["memory_search", "memory_get"]`, or `["memory_recall"]` when `plugins.slots.memory` is `memory-lancedb`; wildcards, `group:*` entries, and core agent tools are ignored |
| `config.thinking` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh" \| "adaptive" \| "max"` | Advanced thinking override for the blocking sub-agent; default `off` for speed |
| `config.fastMode` | `boolean \| "auto"` | Optional fast-mode override for the blocking sub-agent; unset inherits normal agent, session, and model defaults |
| `config.promptOverride` | `string` | Advanced full prompt replacement; not recommended for normal use |
| `config.promptAppend` | `string` | Advanced extra instructions appended to the default or overridden prompt |
| `config.timeoutMs` | `number` | Hard timeout for the blocking sub-agent (range 250-120000 ms; default 15000) |
+28
View File
@@ -26,6 +26,34 @@ describe("active-memory manifest config schema", () => {
expect(result.ok).toBe(true);
});
it.each([true, false, "auto"])("accepts fastMode=%s", (fastMode) => {
const result = validateJsonSchemaValue({
schema: manifest.configSchema,
cacheKey: `active-memory.manifest.fast-mode.${String(fastMode)}`,
value: {
enabled: true,
agents: ["main"],
fastMode,
},
});
expect(result.ok).toBe(true);
});
it("rejects unsupported fastMode strings", () => {
const result = validateJsonSchemaValue({
schema: manifest.configSchema,
cacheKey: "active-memory.manifest.fast-mode.invalid",
value: {
enabled: true,
agents: ["main"],
fastMode: "on",
},
});
expect(result.ok).toBe(false);
});
it("accepts custom toolsAllow entries", () => {
const result = validateJsonSchemaValue({
schema: manifest.configSchema,
+6
View File
@@ -30,6 +30,7 @@ import {
MAX_SETUP_GRACE_TIMEOUT_MS,
MAX_TIMEOUT_MS,
type ActiveMemoryChatType,
type ActiveMemoryFastMode,
type ActiveMemoryPromptStyle,
type ActiveMemoryQmdSearchMode,
type ActiveMemoryThinkingLevel,
@@ -239,6 +240,7 @@ function normalizePluginConfig(
allowedChatIds: normalizeChatIdList(raw.allowedChatIds),
deniedChatIds: normalizeChatIdList(raw.deniedChatIds),
thinking: resolveThinkingLevel(raw.thinking),
fastMode: resolveFastMode(raw.fastMode),
promptStyle: resolvePromptStyle(raw.promptStyle, raw.queryMode),
toolsAllow: resolveToolsAllow({ pluginToolsAllow: raw.toolsAllow, cfg }),
promptOverride: normalizePromptConfigText(raw.promptOverride),
@@ -345,6 +347,10 @@ function resolveThinkingLevel(thinking: unknown): ActiveMemoryThinkingLevel {
return "off";
}
function resolveFastMode(fastMode: unknown): ActiveMemoryFastMode | undefined {
return fastMode === true || fastMode === false || fastMode === "auto" ? fastMode : undefined;
}
function resolvePromptStyle(
promptStyle: unknown,
queryMode: ActiveRecallPluginConfig["queryMode"],
+65 -1
View File
@@ -2201,7 +2201,7 @@ describe("active-memory plugin", () => {
);
});
it("keeps thinking off by default but allows an explicit thinking override", async () => {
it("keeps thinking off and fast mode inherited by default but allows explicit overrides", async () => {
await requireHook("before_prompt_build")(
{
prompt: "What is my favorite food? default-thinking-check",
@@ -2216,11 +2216,14 @@ describe("active-memory plugin", () => {
);
expect(lastEmbeddedRunParams().thinkLevel).toBe("off");
expect(lastEmbeddedRunParams().fastMode).toBeUndefined();
expect(lastEmbeddedRunParams().reasoningLevel).toBe("off");
api.pluginConfig = {
agents: ["main"],
thinking: "medium",
fastMode: true,
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
@@ -2238,7 +2241,60 @@ describe("active-memory plugin", () => {
);
expect(lastEmbeddedRunParams().thinkLevel).toBe("medium");
expect(lastEmbeddedRunParams().fastMode).toBe(true);
expect(lastEmbeddedRunParams().reasoningLevel).toBe("off");
const infoLines = vi
.mocked(api.logger.info)
.mock.calls.map((call: unknown[]) => String(call[0]));
expectLinesToContain(infoLines, "thinking=medium fast=on start");
});
it("inherits parent session fast mode before the agent default", async () => {
api.config = {
agents: {
defaults: {
model: { primary: "github-copilot/gpt-5.4-mini" },
},
list: [{ id: "main", fastModeDefault: true }],
},
};
hoisted.sessionStore["agent:main:main"] = {
sessionId: "s-main",
updatedAt: 0,
fastMode: false,
};
await requireHook("before_prompt_build")(
{
prompt: "What is my favorite food? session-fast-mode-check",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(lastEmbeddedRunParams().fastMode).toBe(false);
delete hoisted.sessionStore["agent:main:main"].fastMode;
await requireHook("before_prompt_build")(
{
prompt: "What is my favorite food? agent-fast-mode-check",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(lastEmbeddedRunParams().fastMode).toBe(true);
});
it("allows appending extra prompt instructions without replacing the base prompt", async () => {
@@ -6429,6 +6485,14 @@ describe("active-memory plugin", () => {
expect(config.circuitBreakerCooldownMs).toBe(60_000);
});
it("normalizes explicit fast-mode overrides and ignores invalid values", () => {
expect(testing.normalizePluginConfig({}).fastMode).toBeUndefined();
expect(testing.normalizePluginConfig({ fastMode: true }).fastMode).toBe(true);
expect(testing.normalizePluginConfig({ fastMode: false }).fastMode).toBe(false);
expect(testing.normalizePluginConfig({ fastMode: "auto" }).fastMode).toBe("auto");
expect(testing.normalizePluginConfig({ fastMode: "on" }).fastMode).toBeUndefined();
});
it("normalizes setup grace config with a zero default and bounded opt-in", () => {
expect(testing.normalizePluginConfig({}).setupGraceTimeoutMs).toBe(0);
expect(testing.normalizePluginConfig({ setupGraceTimeoutMs: 30_001 }).setupGraceTimeoutMs).toBe(
@@ -39,6 +39,12 @@
"type": "string",
"enum": ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"]
},
"fastMode": {
"anyOf": [
{ "type": "boolean" },
{ "type": "string", "enum": ["auto"] }
]
},
"timeoutMs": { "type": "integer", "minimum": 250, "maximum": 120000 },
"setupGraceTimeoutMs": { "type": "integer", "minimum": 0, "maximum": 30000 },
"queryMode": {
@@ -146,6 +152,10 @@
"label": "Thinking Override",
"help": "Advanced: optional thinking level for the blocking memory sub-agent. Defaults to off for speed."
},
"fastMode": {
"label": "Fast Mode Override",
"help": "Advanced: optional fast-mode override for the blocking memory sub-agent. Leave unset to inherit normal agent, session, and model defaults."
},
"promptOverride": {
"label": "Prompt Override",
"help": "Advanced: replace the default Active Memory sub-agent instructions. Conversation context is still appended."
+42 -1
View File
@@ -2,7 +2,11 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
import {
resolveAgentConfig,
resolveAgentDir,
resolveAgentWorkspaceDir,
} from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
import {
@@ -40,11 +44,40 @@ import { fileTranscriptSource, transcriptSourceFromReturnedSessionFile } from ".
import {
ACTIVE_MEMORY_CLEANUP_RETRY_DELAYS_MS,
ACTIVE_MEMORY_RECALL_LANE,
type ActiveMemoryFastMode,
type ActiveMemoryTranscriptSource,
type RecallSubagentResult,
type ResolvedActiveRecallPluginConfig,
} from "./types.js";
function normalizeRecallFastMode(value: unknown): ActiveMemoryFastMode | undefined {
return value === true || value === false || value === "auto" ? value : undefined;
}
function resolveRecallFastMode(params: {
api: OpenClawPluginApi;
config: ResolvedActiveRecallPluginConfig;
agentId: string;
parentSessionKey?: string;
storePath: string;
}): ActiveMemoryFastMode | undefined {
if (params.config.fastMode !== undefined) {
return params.config.fastMode;
}
const sessionFastMode = params.parentSessionKey
? params.api.runtime.agent.session.getSessionEntry({
agentId: params.agentId,
sessionKey: params.parentSessionKey,
storePath: params.storePath,
readConsistency: "latest",
})?.fastMode
: undefined;
return (
normalizeRecallFastMode(sessionFastMode) ??
normalizeRecallFastMode(resolveAgentConfig(params.api.config, params.agentId)?.fastModeDefault)
);
}
function collectActiveMemoryTranscriptSources(params: {
artifactSessionFile: string;
runtimeSource: ActiveMemoryTranscriptSource;
@@ -207,6 +240,13 @@ async function runRecallSubagent(params: {
agentId: params.agentId,
},
);
const fastMode = resolveRecallFastMode({
api: params.api,
config: params.config,
agentId: params.agentId,
parentSessionKey,
storePath,
});
const runtimeSessionFile = formatSqliteSessionFileMarker({
agentId: params.agentId,
sessionId: subagentSessionId,
@@ -300,6 +340,7 @@ async function runRecallSubagent(params: {
bootstrapContextMode: "lightweight",
verboseLevel: "off",
thinkLevel: params.config.thinking,
fastMode,
reasoningLevel: "off",
silentExpected: true,
authProfileFailurePolicy: "local",
+10
View File
@@ -68,6 +68,16 @@ async function maybeResolveActiveRecall(params: {
...(resolvedModelRef?.model
? [`activeModel=${toSingleLineLogValue(resolvedModelRef.model)}`]
: []),
`thinking=${params.config.thinking}`,
`fast=${
params.config.fastMode === undefined
? "inherit"
: params.config.fastMode === true
? "on"
: params.config.fastMode === false
? "off"
: "auto"
}`,
].join(" ");
if (cached) {
params.abortSignal?.throwIfAborted();
+4
View File
@@ -132,6 +132,7 @@ type ActiveRecallPluginConfig = {
allowedChatIds?: string[];
deniedChatIds?: string[];
thinking?: ActiveMemoryThinkingLevel;
fastMode?: ActiveMemoryFastMode;
promptStyle?:
| "balanced"
| "strict"
@@ -173,6 +174,7 @@ type ResolvedActiveRecallPluginConfig = {
allowedChatIds: string[];
deniedChatIds: string[];
thinking: ActiveMemoryThinkingLevel;
fastMode?: ActiveMemoryFastMode;
promptStyle:
| "balanced"
| "strict"
@@ -308,6 +310,7 @@ type ActiveMemoryThinkingLevel =
| "xhigh"
| "adaptive"
| "max";
type ActiveMemoryFastMode = boolean | "auto";
type ActiveMemoryPromptStyle =
| "balanced"
| "strict"
@@ -378,6 +381,7 @@ export {
export type {
ActiveMemoryChatType,
ActiveMemoryFastMode,
ActiveMemoryPartialTimeoutError,
ActiveMemoryPromptStyle,
ActiveMemoryQmdSearchMode,
@@ -34,6 +34,7 @@ function successAttempt(provider: string, model: string): EmbeddedRunAttemptResu
type FastModeAttemptParams = {
fastMode?: unknown;
fastModeAutoOnSeconds?: number;
fastModeAutoProgressState?: {
offAnnounced: boolean;
resetAnnounced: boolean;
@@ -68,6 +69,33 @@ describe("runEmbeddedAgent fast auto progress", () => {
resetAgentEventsForTest();
});
it("uses the selected model's auto cutoff when the caller omits one", async () => {
let attemptParams: FastModeAttemptParams | undefined;
mockedRunEmbeddedAttempt.mockImplementationOnce(async (params) => {
attemptParams = params as FastModeAttemptParams;
resolveAttemptFastMode(params);
return successAttempt("ollama", "glm-5.1:cloud");
});
await runEmbeddedAgent({
...overflowBaseRunParams,
provider: "ollama",
model: "glm-5.1:cloud",
config: {
agents: {
defaults: {
models: {
"ollama/glm-5.1:cloud": { params: { fastAutoOnSeconds: 23 } },
},
},
},
},
fastMode: "auto",
});
expect(attemptParams?.fastModeAutoOnSeconds).toBe(23);
});
it("emits auto-off after a tool execution boundary crosses the threshold", async () => {
vi.useFakeTimers();
@@ -4,8 +4,8 @@ import {
} from "../../../auto-reply/reply-payload.js";
import { emitAgentItemEvent } from "../../../infra/agent-events.js";
import { formatErrorMessage } from "../../../infra/errors.js";
import { resolveFastModeModelAutoOnSeconds } from "../../../shared/fast-mode.js";
import {
DEFAULT_FAST_MODE_AUTO_ON_SECONDS,
type FastModeAutoProgressState,
formatFastModeAutoProgressText,
resolveFastModeForElapsed,
@@ -20,8 +20,15 @@ export function createEmbeddedRunProgressController(params: {
startedAtMs: number;
}) {
const fastModeStartedAtMs = params.attempt.fastModeStartedAtMs ?? params.startedAtMs;
// Embedded callers may set only `fastMode: "auto"`; preserve the selected
// model's cutoff instead of silently falling back to the global default.
const fastModeAutoOnSeconds =
params.attempt.fastModeAutoOnSeconds ?? DEFAULT_FAST_MODE_AUTO_ON_SECONDS;
params.attempt.fastModeAutoOnSeconds ??
resolveFastModeModelAutoOnSeconds({
cfg: params.attempt.config,
provider: params.attempt.provider,
model: params.attempt.model,
});
const fastModeAutoProgressState: FastModeAutoProgressState = params.attempt
.fastModeAutoProgressState ?? {
offAnnounced: false,