mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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) |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user