mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(memory): keep memory_search in transient qmd mode (#92639)
Summary: - Merged fix(memory): keep memory_search in transient qmd mode after ClawSweeper review. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(memory): close transient search managers - PR branch already contained follow-up commit before automerge: fix(memory): preserve default search managers - PR branch already contained follow-up commit before automerge: fix(memory): preserve qmd cli boot freshness Validation: - ClawSweeper review passed for head64fe82c24c. - Required merge gates passed before the squash merge. Prepared head SHA:64fe82c24cReview: https://github.com/openclaw/openclaw/pull/92639#issuecomment-4698763950 Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -29,6 +29,7 @@ type MemoryToolOptions = {
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
sandboxed?: boolean;
|
||||
oneShotCliRun?: boolean;
|
||||
};
|
||||
|
||||
let memoryToolsModulePromise: Promise<MemoryToolsModule> | undefined;
|
||||
@@ -154,6 +155,7 @@ function resolveMemoryToolOptions(ctx: OpenClawPluginToolContext): MemoryToolOpt
|
||||
agentId: ctx.agentId,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
sandboxed: ctx.sandboxed,
|
||||
oneShotCliRun: ctx.oneShotCliRun,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ let workspaceDir = "/workspace";
|
||||
let customStatus: Record<string, unknown> | undefined;
|
||||
let searchImpl: SearchImpl = async () => [];
|
||||
let getManagerImpl:
|
||||
| ((params: { cfg?: unknown; agentId?: string }) => Promise<{
|
||||
| ((params: { cfg?: unknown; agentId?: string; purpose?: string }) => Promise<{
|
||||
manager?: unknown;
|
||||
error?: string;
|
||||
}>)
|
||||
@@ -60,8 +60,9 @@ const stubManager = {
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
const getMemorySearchManagerMock = vi.fn(async (params: { cfg?: unknown; agentId?: string }) =>
|
||||
getManagerImpl ? await getManagerImpl(params) : { manager: stubManager },
|
||||
const getMemorySearchManagerMock = vi.fn(
|
||||
async (params: { cfg?: unknown; agentId?: string; purpose?: string }) =>
|
||||
getManagerImpl ? await getManagerImpl(params) : { manager: stubManager },
|
||||
);
|
||||
const readAgentMemoryFileMock = vi.fn(
|
||||
async (params: MemoryReadParams) => await readFileImpl(params),
|
||||
@@ -97,7 +98,7 @@ export function setMemorySearchImpl(next: SearchImpl): void {
|
||||
}
|
||||
|
||||
export function setMemorySearchManagerImpl(
|
||||
next: (params: { cfg?: unknown; agentId?: string }) => Promise<{
|
||||
next: (params: { cfg?: unknown; agentId?: string; purpose?: string }) => Promise<{
|
||||
manager?: unknown;
|
||||
error?: string;
|
||||
}>,
|
||||
@@ -140,11 +141,19 @@ export function getMemorySyncMockCalls(): number {
|
||||
return stubManager.sync.mock.calls.length;
|
||||
}
|
||||
|
||||
export function getMemoryCloseMockCalls(): number {
|
||||
return stubManager.close.mock.calls.length;
|
||||
}
|
||||
|
||||
export function getMemorySearchManagerMockConfigs(): unknown[] {
|
||||
return getMemorySearchManagerMock.mock.calls.map(([params]) => params.cfg);
|
||||
}
|
||||
|
||||
export function getMemorySearchManagerMockParams(): Array<{ cfg?: unknown; agentId?: string }> {
|
||||
export function getMemorySearchManagerMockParams(): Array<{
|
||||
cfg?: unknown;
|
||||
agentId?: string;
|
||||
purpose?: string;
|
||||
}> {
|
||||
return getMemorySearchManagerMock.mock.calls.map(([params]) => params);
|
||||
}
|
||||
|
||||
|
||||
@@ -797,6 +797,59 @@ describe("QmdMemoryManager", () => {
|
||||
await manager?.close();
|
||||
});
|
||||
|
||||
it("preserves blocking boot update freshness for one-shot CLI mode", async () => {
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: {
|
||||
interval: "5m",
|
||||
debounceMs: 60_000,
|
||||
onBoot: true,
|
||||
waitForBootSync: true,
|
||||
},
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const updateSpawned = createDeferred<void>();
|
||||
let releaseUpdate: (() => void) | null = null;
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "update") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
releaseUpdate = () => child.closeWith(0);
|
||||
updateSpawned.resolve();
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const createPromise = createManager({ mode: "cli" });
|
||||
await updateSpawned.promise;
|
||||
let created = false;
|
||||
void createPromise.then(() => {
|
||||
created = true;
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(created).toBe(false);
|
||||
expect(watchMock).not.toHaveBeenCalled();
|
||||
|
||||
(releaseUpdate as (() => void) | null)?.();
|
||||
const { manager } = await createPromise;
|
||||
const updateCalls = spawnMock.mock.calls
|
||||
.map((call: unknown[]) => call[1] as string[])
|
||||
.filter((args: string[]) => args[0] === "update" || args[0] === "embed");
|
||||
expect(updateCalls).toStrictEqual([["update"]]);
|
||||
expect(watchMock).not.toHaveBeenCalled();
|
||||
|
||||
await manager?.close();
|
||||
});
|
||||
|
||||
it("keeps one-shot CLI searches from scheduling session-start updates", async () => {
|
||||
cfg = {
|
||||
...cfg,
|
||||
|
||||
@@ -497,6 +497,11 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
|
||||
await this.ensureCollections();
|
||||
if (mode === "cli") {
|
||||
if (this.qmd.update.onBoot && this.qmd.update.waitForBootSync) {
|
||||
await this.runUpdate("boot:cli", true).catch((err: unknown) => {
|
||||
log.warn(`qmd cli boot update failed: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
log.info(
|
||||
`qmd manager initialized for agent "${this.agentId}" mode=cli collections=${this.qmd.collections.length} durationMs=${Date.now() - startTime}`,
|
||||
);
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
import { readMemoryHostEvents } from "openclaw/plugin-sdk/memory-host-events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getMemoryCloseMockCalls,
|
||||
getMemorySearchManagerMockCalls,
|
||||
getMemorySearchManagerMockParams,
|
||||
getReadAgentMemoryFileMockCalls,
|
||||
resetMemoryToolMockState,
|
||||
setMemoryBackend,
|
||||
@@ -162,6 +164,47 @@ describe("memory tools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses default memory manager mode for shared memory_search", async () => {
|
||||
setMemoryBackend("qmd");
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: asOpenClawConfig({
|
||||
memory: { backend: "qmd", qmd: { command: "qmd" } },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
}),
|
||||
});
|
||||
|
||||
await tool.execute("call_default_purpose", { query: "contact phrase" });
|
||||
|
||||
expect(getMemorySearchManagerMockParams()).toEqual([
|
||||
expect.objectContaining({
|
||||
agentId: "main",
|
||||
purpose: undefined,
|
||||
}),
|
||||
]);
|
||||
expect(getMemoryCloseMockCalls()).toBe(0);
|
||||
});
|
||||
|
||||
it("uses one-shot CLI memory manager mode for explicit local CLI memory_search", async () => {
|
||||
setMemoryBackend("qmd");
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: asOpenClawConfig({
|
||||
memory: { backend: "qmd", qmd: { command: "qmd" } },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
}),
|
||||
oneShotCliRun: true,
|
||||
});
|
||||
|
||||
await tool.execute("call_cli_purpose", { query: "contact phrase" });
|
||||
|
||||
expect(getMemorySearchManagerMockParams()).toEqual([
|
||||
expect.objectContaining({
|
||||
agentId: "main",
|
||||
purpose: "cli",
|
||||
}),
|
||||
]);
|
||||
expect(getMemoryCloseMockCalls()).toBe(1);
|
||||
});
|
||||
|
||||
it("returns disabled details when memory_get fails", async () => {
|
||||
setMemoryReadFileImpl(async (_params: MemoryReadParams) => {
|
||||
throw new Error("path required");
|
||||
|
||||
@@ -20,6 +20,7 @@ type MemoryToolOptions = {
|
||||
getConfig?: () => OpenClawConfig | undefined;
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
oneShotCliRun?: boolean;
|
||||
};
|
||||
|
||||
let memoryToolRuntimePromise: Promise<MemoryToolRuntime> | null = null;
|
||||
|
||||
@@ -15,11 +15,13 @@ export function createMemorySearchToolOrThrow(params?: {
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
oneShotCliRun?: boolean;
|
||||
}) {
|
||||
const tool = createMemorySearchTool({
|
||||
config: params?.config ?? createDefaultMemoryToolConfig(),
|
||||
...(params?.agentId ? { agentId: params.agentId } : {}),
|
||||
...(params?.agentSessionKey ? { agentSessionKey: params.agentSessionKey } : {}),
|
||||
...(params?.oneShotCliRun ? { oneShotCliRun: params.oneShotCliRun } : {}),
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("tool missing");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Memory Core tests cover tools plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getMemoryCloseMockCalls,
|
||||
getMemorySearchManagerMockCalls,
|
||||
getMemorySearchManagerMockConfigs,
|
||||
getMemorySearchManagerMockParams,
|
||||
@@ -257,6 +258,59 @@ describe("memory_search unavailable payloads", () => {
|
||||
]);
|
||||
expect(searchCalls).toBe(2);
|
||||
expect(getMemorySearchManagerMockCalls()).toBe(2);
|
||||
expect(getMemorySearchManagerMockParams()).toEqual([
|
||||
expect.objectContaining({ purpose: undefined }),
|
||||
expect.objectContaining({ purpose: undefined }),
|
||||
]);
|
||||
expect(getMemoryCloseMockCalls()).toBe(0);
|
||||
});
|
||||
|
||||
it("re-resolves and closes one-shot CLI managers when a cached sqlite handle was closed", async () => {
|
||||
let searchCalls = 0;
|
||||
setMemorySearchImpl(async () => {
|
||||
searchCalls += 1;
|
||||
if (searchCalls === 1) {
|
||||
throw new Error("database is not open");
|
||||
}
|
||||
return [
|
||||
{
|
||||
path: "MEMORY.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 0.9,
|
||||
snippet: "Thread-hidden codename: ORBIT-22.",
|
||||
source: "memory" as const,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
memory: { citations: "off" },
|
||||
},
|
||||
oneShotCliRun: true,
|
||||
});
|
||||
const result = await tool.execute("closed-db-cli", { query: "hidden thread codename" });
|
||||
|
||||
expect((result.details as { results?: Array<{ path: string }> }).results).toEqual([
|
||||
{
|
||||
corpus: "memory",
|
||||
path: "MEMORY.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 0.9,
|
||||
snippet: "Thread-hidden codename: ORBIT-22.",
|
||||
source: "memory",
|
||||
},
|
||||
]);
|
||||
expect(searchCalls).toBe(2);
|
||||
expect(getMemorySearchManagerMockCalls()).toBe(2);
|
||||
expect(getMemorySearchManagerMockParams()).toEqual([
|
||||
expect.objectContaining({ purpose: "cli" }),
|
||||
expect.objectContaining({ purpose: "cli" }),
|
||||
]);
|
||||
expect(getMemoryCloseMockCalls()).toBe(1);
|
||||
});
|
||||
|
||||
it("forces a sync and retries once when the first search has zero hits", async () => {
|
||||
|
||||
+223
-177
@@ -32,7 +32,6 @@ import {
|
||||
buildMemorySearchUnavailableResult,
|
||||
createMemoryTool,
|
||||
getMemoryCorpusSupplementResult,
|
||||
getMemoryManagerContext,
|
||||
getMemoryManagerContextWithPurpose,
|
||||
loadMemoryToolRuntime,
|
||||
MemoryGetSchema,
|
||||
@@ -43,6 +42,8 @@ import {
|
||||
type MemorySearchToolResult =
|
||||
| (MemorySearchResult & { corpus: MemorySource })
|
||||
| MemoryCorpusSearchResult;
|
||||
type MemoryManagerContext = Awaited<ReturnType<typeof getMemoryManagerContextWithPurpose>>;
|
||||
type ActiveMemoryManagerContext = Extract<MemoryManagerContext, { manager: unknown }>;
|
||||
|
||||
const MEMORY_SEARCH_TOOL_TIMEOUT_MS = 15_000;
|
||||
const MEMORY_SEARCH_TOOL_COOLDOWN_MS = 60_000;
|
||||
@@ -81,6 +82,24 @@ export const testing = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
function isActiveMemoryManagerContext(
|
||||
context: MemoryManagerContext | null,
|
||||
): context is ActiveMemoryManagerContext {
|
||||
return context !== null && "manager" in context;
|
||||
}
|
||||
|
||||
async function closeMemoryManagers(
|
||||
managers: Iterable<ActiveMemoryManagerContext["manager"]>,
|
||||
): Promise<void> {
|
||||
for (const manager of managers) {
|
||||
try {
|
||||
await manager.close?.();
|
||||
} catch {
|
||||
// Search results should not be hidden by best-effort transient cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runMemorySearchToolWithDeadline<T>(params: {
|
||||
timeoutMs: number;
|
||||
run: (signal: AbortSignal) => Promise<T>;
|
||||
@@ -346,6 +365,7 @@ export function createMemorySearchTool(options: {
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
sandboxed?: boolean;
|
||||
oneShotCliRun?: boolean;
|
||||
}) {
|
||||
return createMemoryTool({
|
||||
options,
|
||||
@@ -401,191 +421,217 @@ export function createMemorySearchTool(options: {
|
||||
if (cooldown && !shouldQuerySupplements) {
|
||||
return jsonResult(buildMemorySearchUnavailableResult(cooldown.error));
|
||||
}
|
||||
const memory = shouldQueryMemory
|
||||
? await runUnavailablePhase(
|
||||
"memory",
|
||||
async () => await getMemoryManagerContext({ cfg, agentId }),
|
||||
)
|
||||
: null;
|
||||
if (shouldQueryMemory && memory && "error" in memory && !shouldQuerySupplements) {
|
||||
recordMemorySearchToolCooldown(
|
||||
cooldownKey,
|
||||
memory.error ?? "memory search unavailable",
|
||||
);
|
||||
return jsonResult(buildMemorySearchUnavailableResult(memory.error));
|
||||
}
|
||||
|
||||
const citationsMode = resolveMemoryCitationsMode(cfg);
|
||||
const includeCitations = shouldIncludeCitations({
|
||||
mode: citationsMode,
|
||||
sessionKey: options.agentSessionKey,
|
||||
});
|
||||
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
|
||||
const dreamingEnabled = resolveMemoryDreamingConfig({
|
||||
pluginConfig,
|
||||
cfg,
|
||||
}).enabled;
|
||||
const dreaming = resolveMemoryDeepDreamingConfig({
|
||||
pluginConfig,
|
||||
cfg,
|
||||
});
|
||||
const searchStartedAt = Date.now();
|
||||
let rawResults: MemorySearchResult[] = [];
|
||||
let surfacedMemoryResults: Array<MemorySearchResult & { corpus: MemorySource }> = [];
|
||||
let provider: string | undefined;
|
||||
let model: string | undefined;
|
||||
let fallback: unknown;
|
||||
let searchMode: string | undefined;
|
||||
let pausedIndexIdentityReason: string | undefined;
|
||||
let searchDebug:
|
||||
| {
|
||||
backend: string;
|
||||
configuredMode?: string;
|
||||
effectiveMode?: string;
|
||||
fallback?: string;
|
||||
searchMs: number;
|
||||
hits: number;
|
||||
}
|
||||
| undefined;
|
||||
if (shouldQueryMemory && memory && !("error" in memory)) {
|
||||
await runUnavailablePhase("memory", async () => {
|
||||
let activeMemory = memory;
|
||||
const runtimeDebug: MemorySearchRuntimeDebug[] = [];
|
||||
const qmdSearchModeOverride = resolveActiveMemoryQmdSearchModeOverride(
|
||||
cfg,
|
||||
options.agentSessionKey,
|
||||
const memoryManagerPurpose = options.oneShotCliRun ? "cli" : undefined;
|
||||
const memoryManagersToClose = new Set<ActiveMemoryManagerContext["manager"]>();
|
||||
const trackMemoryManager = (context: MemoryManagerContext): MemoryManagerContext => {
|
||||
if (memoryManagerPurpose === "cli" && isActiveMemoryManagerContext(context)) {
|
||||
memoryManagersToClose.add(context.manager);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
try {
|
||||
const memory = shouldQueryMemory
|
||||
? await runUnavailablePhase("memory", async () =>
|
||||
trackMemoryManager(
|
||||
await getMemoryManagerContextWithPurpose({
|
||||
cfg,
|
||||
agentId,
|
||||
purpose: memoryManagerPurpose,
|
||||
}),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
if (shouldQueryMemory && memory && "error" in memory && !shouldQuerySupplements) {
|
||||
recordMemorySearchToolCooldown(
|
||||
cooldownKey,
|
||||
memory.error ?? "memory search unavailable",
|
||||
);
|
||||
const searchSources: MemorySource[] | undefined =
|
||||
requestedCorpus === "sessions"
|
||||
? (["sessions"] as MemorySource[])
|
||||
: requestedCorpus === "memory"
|
||||
? (["memory"] as MemorySource[])
|
||||
: undefined;
|
||||
const searchOptions = {
|
||||
maxResults,
|
||||
minScore,
|
||||
sessionKey: options.agentSessionKey,
|
||||
qmdSearchModeOverride,
|
||||
signal: deadlineSignal,
|
||||
onDebug: (debug: MemorySearchRuntimeDebug) => {
|
||||
runtimeDebug.push(debug);
|
||||
},
|
||||
...(searchSources ? { sources: searchSources } : {}),
|
||||
};
|
||||
try {
|
||||
rawResults = await activeMemory.manager.search(query, searchOptions);
|
||||
} catch (error) {
|
||||
if (!isClosedMemoryStoreError(error)) {
|
||||
throw error;
|
||||
return jsonResult(buildMemorySearchUnavailableResult(memory.error));
|
||||
}
|
||||
|
||||
const citationsMode = resolveMemoryCitationsMode(cfg);
|
||||
const includeCitations = shouldIncludeCitations({
|
||||
mode: citationsMode,
|
||||
sessionKey: options.agentSessionKey,
|
||||
});
|
||||
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
|
||||
const dreamingEnabled = resolveMemoryDreamingConfig({
|
||||
pluginConfig,
|
||||
cfg,
|
||||
}).enabled;
|
||||
const dreaming = resolveMemoryDeepDreamingConfig({
|
||||
pluginConfig,
|
||||
cfg,
|
||||
});
|
||||
const searchStartedAt = Date.now();
|
||||
let rawResults: MemorySearchResult[] = [];
|
||||
let surfacedMemoryResults: Array<MemorySearchResult & { corpus: MemorySource }> = [];
|
||||
let provider: string | undefined;
|
||||
let model: string | undefined;
|
||||
let fallback: unknown;
|
||||
let searchMode: string | undefined;
|
||||
let pausedIndexIdentityReason: string | undefined;
|
||||
let searchDebug:
|
||||
| {
|
||||
backend: string;
|
||||
configuredMode?: string;
|
||||
effectiveMode?: string;
|
||||
fallback?: string;
|
||||
searchMs: number;
|
||||
hits: number;
|
||||
}
|
||||
const refreshed = await getMemoryManagerContext({ cfg, agentId });
|
||||
if ("error" in refreshed) {
|
||||
throw error;
|
||||
}
|
||||
activeMemory = refreshed;
|
||||
rawResults = await activeMemory.manager.search(query, searchOptions);
|
||||
}
|
||||
const statusBeforeRetry = activeMemory.manager.status();
|
||||
pausedIndexIdentityReason =
|
||||
resolvePausedMemoryIndexIdentityReason(statusBeforeRetry);
|
||||
if (pausedIndexIdentityReason) {
|
||||
return;
|
||||
}
|
||||
if (rawResults.length === 0 && activeMemory.manager.sync) {
|
||||
await activeMemory.manager.sync({ reason: "search", force: true });
|
||||
rawResults = await activeMemory.manager.search(query, searchOptions);
|
||||
pausedIndexIdentityReason = resolvePausedMemoryIndexIdentityReason(
|
||||
activeMemory.manager.status(),
|
||||
| undefined;
|
||||
if (shouldQueryMemory && memory && !("error" in memory)) {
|
||||
await runUnavailablePhase("memory", async () => {
|
||||
let activeMemory = memory;
|
||||
const runtimeDebug: MemorySearchRuntimeDebug[] = [];
|
||||
const qmdSearchModeOverride = resolveActiveMemoryQmdSearchModeOverride(
|
||||
cfg,
|
||||
options.agentSessionKey,
|
||||
);
|
||||
const searchSources: MemorySource[] | undefined =
|
||||
requestedCorpus === "sessions"
|
||||
? (["sessions"] as MemorySource[])
|
||||
: requestedCorpus === "memory"
|
||||
? (["memory"] as MemorySource[])
|
||||
: undefined;
|
||||
const searchOptions = {
|
||||
maxResults,
|
||||
minScore,
|
||||
sessionKey: options.agentSessionKey,
|
||||
qmdSearchModeOverride,
|
||||
signal: deadlineSignal,
|
||||
onDebug: (debug: MemorySearchRuntimeDebug) => {
|
||||
runtimeDebug.push(debug);
|
||||
},
|
||||
...(searchSources ? { sources: searchSources } : {}),
|
||||
};
|
||||
try {
|
||||
rawResults = await activeMemory.manager.search(query, searchOptions);
|
||||
} catch (error) {
|
||||
if (!isClosedMemoryStoreError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const refreshed = trackMemoryManager(
|
||||
await getMemoryManagerContextWithPurpose({
|
||||
cfg,
|
||||
agentId,
|
||||
purpose: memoryManagerPurpose,
|
||||
}),
|
||||
);
|
||||
if ("error" in refreshed) {
|
||||
throw error;
|
||||
}
|
||||
activeMemory = refreshed;
|
||||
rawResults = await activeMemory.manager.search(query, searchOptions);
|
||||
}
|
||||
const statusBeforeRetry = activeMemory.manager.status();
|
||||
pausedIndexIdentityReason =
|
||||
resolvePausedMemoryIndexIdentityReason(statusBeforeRetry);
|
||||
if (pausedIndexIdentityReason) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
rawResults = await filterMemorySearchHitsBySessionVisibility({
|
||||
cfg,
|
||||
agentId,
|
||||
requesterSessionKey: options.agentSessionKey,
|
||||
sandboxed: options.sandboxed === true,
|
||||
hits: rawResults,
|
||||
});
|
||||
if (requestedCorpus === "sessions") {
|
||||
rawResults = rawResults.filter((hit) => hit.source === "sessions");
|
||||
} else if (requestedCorpus === "memory") {
|
||||
rawResults = rawResults.filter((hit) => hit.source === "memory");
|
||||
}
|
||||
const status = activeMemory.manager.status();
|
||||
const decorated = decorateCitations(rawResults, includeCitations);
|
||||
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
||||
const memoryResults =
|
||||
status.backend === "qmd"
|
||||
? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars)
|
||||
: decorated;
|
||||
surfacedMemoryResults = memoryResults.map((result) => ({
|
||||
...result,
|
||||
corpus: result.source,
|
||||
}));
|
||||
if (dreamingEnabled) {
|
||||
queueShortTermRecallTracking({
|
||||
workspaceDir: status.workspaceDir,
|
||||
query,
|
||||
rawResults,
|
||||
surfacedResults: memoryResults,
|
||||
timezone: dreaming.timezone,
|
||||
if (rawResults.length === 0 && activeMemory.manager.sync) {
|
||||
await activeMemory.manager.sync({ reason: "search", force: true });
|
||||
rawResults = await activeMemory.manager.search(query, searchOptions);
|
||||
pausedIndexIdentityReason = resolvePausedMemoryIndexIdentityReason(
|
||||
activeMemory.manager.status(),
|
||||
);
|
||||
if (pausedIndexIdentityReason) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
rawResults = await filterMemorySearchHitsBySessionVisibility({
|
||||
cfg,
|
||||
agentId,
|
||||
requesterSessionKey: options.agentSessionKey,
|
||||
sandboxed: options.sandboxed === true,
|
||||
hits: rawResults,
|
||||
});
|
||||
}
|
||||
provider = status.provider;
|
||||
model = status.model;
|
||||
fallback = status.fallback;
|
||||
const latestDebug = runtimeDebug.at(-1);
|
||||
searchMode = latestDebug?.effectiveMode;
|
||||
searchDebug = {
|
||||
backend: status.backend,
|
||||
configuredMode: latestDebug?.configuredMode,
|
||||
effectiveMode:
|
||||
if (requestedCorpus === "sessions") {
|
||||
rawResults = rawResults.filter((hit) => hit.source === "sessions");
|
||||
} else if (requestedCorpus === "memory") {
|
||||
rawResults = rawResults.filter((hit) => hit.source === "memory");
|
||||
}
|
||||
const status = activeMemory.manager.status();
|
||||
const decorated = decorateCitations(rawResults, includeCitations);
|
||||
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
||||
const memoryResults =
|
||||
status.backend === "qmd"
|
||||
? (latestDebug?.effectiveMode ?? latestDebug?.configuredMode)
|
||||
: "n/a",
|
||||
fallback: latestDebug?.fallback,
|
||||
searchMs: Math.max(0, Date.now() - searchStartedAt),
|
||||
hits: rawResults.length,
|
||||
};
|
||||
});
|
||||
if (pausedIndexIdentityReason) {
|
||||
return jsonResult(
|
||||
buildPausedMemoryIndexUnavailableResult(pausedIndexIdentityReason),
|
||||
);
|
||||
}
|
||||
}
|
||||
const supplementResults = shouldQuerySupplements
|
||||
? await runUnavailablePhase(
|
||||
"supplement",
|
||||
async () =>
|
||||
await searchMemoryCorpusSupplements({
|
||||
? clampResultsByInjectedChars(
|
||||
decorated,
|
||||
resolved.qmd?.limits.maxInjectedChars,
|
||||
)
|
||||
: decorated;
|
||||
surfacedMemoryResults = memoryResults.map((result) => ({
|
||||
...result,
|
||||
corpus: result.source,
|
||||
}));
|
||||
if (dreamingEnabled) {
|
||||
queueShortTermRecallTracking({
|
||||
workspaceDir: status.workspaceDir,
|
||||
query,
|
||||
maxResults,
|
||||
agentSessionKey: options.agentSessionKey,
|
||||
corpus: requestedCorpus,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
// Wiki and memory scores use incomparable scales, so corpus=all first
|
||||
// balances candidate selection and then backfills any unused slots.
|
||||
const effectiveMax = Math.max(1, maxResults ?? 10);
|
||||
const results = mergeMemorySearchCorpusResults({
|
||||
memoryResults: surfacedMemoryResults,
|
||||
supplementResults,
|
||||
maxResults: effectiveMax,
|
||||
balanceCorpora: requestedCorpus === "all",
|
||||
});
|
||||
return jsonResult({
|
||||
results,
|
||||
provider,
|
||||
model,
|
||||
fallback,
|
||||
citations: citationsMode,
|
||||
mode: searchMode,
|
||||
debug: searchDebug,
|
||||
});
|
||||
rawResults,
|
||||
surfacedResults: memoryResults,
|
||||
timezone: dreaming.timezone,
|
||||
});
|
||||
}
|
||||
provider = status.provider;
|
||||
model = status.model;
|
||||
fallback = status.fallback;
|
||||
const latestDebug = runtimeDebug.at(-1);
|
||||
searchMode = latestDebug?.effectiveMode;
|
||||
searchDebug = {
|
||||
backend: status.backend,
|
||||
configuredMode: latestDebug?.configuredMode,
|
||||
effectiveMode:
|
||||
status.backend === "qmd"
|
||||
? (latestDebug?.effectiveMode ?? latestDebug?.configuredMode)
|
||||
: "n/a",
|
||||
fallback: latestDebug?.fallback,
|
||||
searchMs: Math.max(0, Date.now() - searchStartedAt),
|
||||
hits: rawResults.length,
|
||||
};
|
||||
});
|
||||
if (pausedIndexIdentityReason) {
|
||||
return jsonResult(
|
||||
buildPausedMemoryIndexUnavailableResult(pausedIndexIdentityReason),
|
||||
);
|
||||
}
|
||||
}
|
||||
const supplementResults = shouldQuerySupplements
|
||||
? await runUnavailablePhase(
|
||||
"supplement",
|
||||
async () =>
|
||||
await searchMemoryCorpusSupplements({
|
||||
query,
|
||||
maxResults,
|
||||
agentSessionKey: options.agentSessionKey,
|
||||
corpus: requestedCorpus,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
// Wiki and memory scores use incomparable scales, so corpus=all first
|
||||
// balances candidate selection and then backfills any unused slots.
|
||||
const effectiveMax = Math.max(1, maxResults ?? 10);
|
||||
const results = mergeMemorySearchCorpusResults({
|
||||
memoryResults: surfacedMemoryResults,
|
||||
supplementResults,
|
||||
maxResults: effectiveMax,
|
||||
balanceCorpora: requestedCorpus === "all",
|
||||
});
|
||||
return jsonResult({
|
||||
results,
|
||||
provider,
|
||||
model,
|
||||
fallback,
|
||||
citations: citationsMode,
|
||||
mode: searchMode,
|
||||
debug: searchDebug,
|
||||
});
|
||||
} finally {
|
||||
await closeMemoryManagers(memoryManagersToClose);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (outcome.status === "unavailable") {
|
||||
|
||||
@@ -428,6 +428,11 @@ export function createOpenClawCodingTools(options?: {
|
||||
runSessionKey?: string;
|
||||
/** Ephemeral session UUID — regenerated on /new and /reset. */
|
||||
sessionId?: string;
|
||||
/**
|
||||
* Explicit one-shot local CLI runs should not keep plugin-owned process
|
||||
* resources alive after emitting their result.
|
||||
*/
|
||||
oneShotCliRun?: boolean;
|
||||
/** Stable run identifier for this agent invocation. */
|
||||
runId?: string;
|
||||
/** Diagnostic trace context for hook/log correlation during this run. */
|
||||
@@ -939,6 +944,7 @@ export function createOpenClawCodingTools(options?: {
|
||||
fsPolicy,
|
||||
requesterSenderId: options?.senderId,
|
||||
sessionId: options?.sessionId,
|
||||
oneShotCliRun: options?.oneShotCliRun,
|
||||
sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl,
|
||||
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
|
||||
sandboxed: Boolean(sandbox),
|
||||
@@ -1052,6 +1058,7 @@ export function createOpenClawCodingTools(options?: {
|
||||
senderIsOwner: options?.senderIsOwner,
|
||||
authProfileStore: options?.authProfileStore,
|
||||
sessionId: options?.sessionId,
|
||||
oneShotCliRun: options?.oneShotCliRun,
|
||||
inheritedToolAllowlist,
|
||||
inheritedToolDenylist,
|
||||
onYield: options?.onYield,
|
||||
|
||||
@@ -126,6 +126,8 @@ export type RunCliAgentParams = {
|
||||
* alive after the JSON response is emitted.
|
||||
*/
|
||||
cleanupBundleMcpOnRunEnd?: boolean;
|
||||
/** Mark explicit one-shot local CLI runs so plugin tools can release resources promptly. */
|
||||
oneShotCliRun?: boolean;
|
||||
};
|
||||
|
||||
/** Backend config after MCP, skill, env, and cleanup preparation. */
|
||||
|
||||
@@ -643,6 +643,7 @@ export function runAgentAttempt(params: {
|
||||
toolsAllow: params.opts.toolsAllow,
|
||||
cleanupBundleMcpOnRunEnd: params.opts.cleanupBundleMcpOnRunEnd,
|
||||
cleanupCliLiveSessionOnRunEnd: params.opts.cleanupCliLiveSessionOnRunEnd,
|
||||
oneShotCliRun: params.opts.oneShotCliRun,
|
||||
...(mutableCliSessionStore
|
||||
? {
|
||||
onBeforeFreshCliSessionRetry: async (retry) => {
|
||||
@@ -746,6 +747,7 @@ export function runAgentAttempt(params: {
|
||||
agentDir: params.agentDir,
|
||||
allowTransientCooldownProbe: params.allowTransientCooldownProbe,
|
||||
cleanupBundleMcpOnRunEnd: params.opts.cleanupBundleMcpOnRunEnd,
|
||||
oneShotCliRun: params.opts.oneShotCliRun,
|
||||
modelRun: params.opts.modelRun,
|
||||
promptMode: params.opts.promptMode,
|
||||
disableTools: params.opts.modelRun === true,
|
||||
|
||||
@@ -135,6 +135,8 @@ export type AgentCommandOpts = {
|
||||
cleanupBundleMcpOnRunEnd?: boolean;
|
||||
/** Force long-lived CLI live session teardown when a one-shot local run completes. */
|
||||
cleanupCliLiveSessionOnRunEnd?: boolean;
|
||||
/** Mark explicit one-shot local CLI runs so plugin tools can release resources promptly. */
|
||||
oneShotCliRun?: boolean;
|
||||
/** Internal local CLI callers can annotate result metadata before JSON/text output. */
|
||||
resultMetaOverrides?: AgentCommandResultMetaOverrides;
|
||||
/** Called when the actual run model is selected, including fallback retries. */
|
||||
|
||||
@@ -96,8 +96,8 @@ import { isFallbackSummaryError, runWithModelFallback } from "../model-fallback.
|
||||
import { supportsModelTools } from "../model-tool-support.js";
|
||||
import { ensureOpenClawModelsJson } from "../models-config.js";
|
||||
import { wrapStreamFnTextTransforms } from "../plugin-text-transforms.js";
|
||||
import { applyPreparedRuntimeAuthToModel } from "../provider-request-config.js";
|
||||
import { resolveAgentPromptSurfaceForSessionKey } from "../prompt-surface.js";
|
||||
import { applyPreparedRuntimeAuthToModel } from "../provider-request-config.js";
|
||||
import { registerProviderStreamForModel } from "../provider-stream.js";
|
||||
import { collectRuntimeChannelCapabilities } from "../runtime-capabilities.js";
|
||||
import { buildAgentRuntimePlan } from "../runtime-plan/build.js";
|
||||
@@ -845,6 +845,7 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
: undefined,
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
oneShotCliRun: params.oneShotCliRun,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
|
||||
@@ -102,6 +102,8 @@ export type CompactEmbeddedAgentSessionParams = {
|
||||
}) => void | Promise<void>;
|
||||
/** Allow runtime plugins for this compaction to late-bind the gateway subagent. */
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
/** Mark explicit one-shot local CLI runs so plugin tools can release resources promptly. */
|
||||
oneShotCliRun?: boolean;
|
||||
};
|
||||
|
||||
export type CompactionMessageMetrics = {
|
||||
|
||||
@@ -1239,6 +1239,7 @@ export async function runEmbeddedAttempt(
|
||||
: undefined,
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
oneShotCliRun: params.oneShotCliRun,
|
||||
toolSearchCatalogRef,
|
||||
agentDir,
|
||||
cwd: effectiveCwd,
|
||||
|
||||
@@ -257,4 +257,6 @@ export type RunEmbeddedAgentParams = {
|
||||
* exit promptly after emitting the final JSON result.
|
||||
*/
|
||||
cleanupBundleMcpOnRunEnd?: boolean;
|
||||
/** Mark explicit one-shot local CLI runs so plugin tools can release resources promptly. */
|
||||
oneShotCliRun?: boolean;
|
||||
};
|
||||
|
||||
@@ -27,6 +27,11 @@ export type OpenClawPluginToolOptions = {
|
||||
requesterSenderId?: string | null;
|
||||
requesterAgentIdOverride?: string;
|
||||
sessionId?: string;
|
||||
/**
|
||||
* Explicit one-shot local CLI runs should not keep plugin-owned process
|
||||
* resources alive after emitting their result.
|
||||
*/
|
||||
oneShotCliRun?: boolean;
|
||||
sandboxBrowserBridgeUrl?: string;
|
||||
allowHostBrowserControl?: boolean;
|
||||
sandboxed?: boolean;
|
||||
@@ -91,6 +96,7 @@ export function resolveOpenClawPluginToolInputs(params: {
|
||||
deliveryContext,
|
||||
requesterSenderId: options?.requesterSenderId ?? undefined,
|
||||
sandboxed: options?.sandboxed,
|
||||
oneShotCliRun: options?.oneShotCliRun,
|
||||
},
|
||||
allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,
|
||||
};
|
||||
|
||||
@@ -164,6 +164,11 @@ export function createOpenClawTools(
|
||||
authProfileStore?: AuthProfileStore;
|
||||
/** Ephemeral session UUID — regenerated on /new and /reset. */
|
||||
sessionId?: string;
|
||||
/**
|
||||
* Explicit one-shot local CLI runs should not keep plugin-owned process
|
||||
* resources alive after emitting their result.
|
||||
*/
|
||||
oneShotCliRun?: boolean;
|
||||
/**
|
||||
* Workspace directory to pass to spawned subagents for inheritance.
|
||||
* Defaults to workspaceDir. Use this to pass the actual agent workspace when the
|
||||
|
||||
@@ -1694,6 +1694,7 @@ describe("agentCliCommand", () => {
|
||||
);
|
||||
expect(localOpts.cleanupBundleMcpOnRunEnd).toBe(true);
|
||||
expect(localOpts.cleanupCliLiveSessionOnRunEnd).toBe(true);
|
||||
expect(localOpts.oneShotCliRun).toBe(true);
|
||||
expect(localOpts).not.toHaveProperty("resultMetaOverrides");
|
||||
expect(runtime.log).toHaveBeenCalledWith("local");
|
||||
});
|
||||
@@ -1789,6 +1790,7 @@ describe("agentCliCommand", () => {
|
||||
);
|
||||
expect(fallbackOpts.cleanupBundleMcpOnRunEnd).toBe(true);
|
||||
expect(fallbackOpts.cleanupCliLiveSessionOnRunEnd).toBe(true);
|
||||
expect(fallbackOpts.oneShotCliRun).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -842,6 +842,7 @@ export async function agentCliCommand(
|
||||
replyAccountId: gatewayDispatchOpts.replyAccount,
|
||||
cleanupBundleMcpOnRunEnd: true,
|
||||
cleanupCliLiveSessionOnRunEnd: true,
|
||||
oneShotCliRun: dispatchOpts.local === true,
|
||||
abortSignal: signalBridge.signal,
|
||||
};
|
||||
try {
|
||||
|
||||
@@ -47,6 +47,11 @@ export type OpenClawPluginToolContext = {
|
||||
/** Trusted sender id from inbound context (runtime-provided, not tool args). */
|
||||
requesterSenderId?: string;
|
||||
sandboxed?: boolean;
|
||||
/**
|
||||
* True for explicit one-shot local CLI runs that must release plugin-owned
|
||||
* process resources before the command exits.
|
||||
*/
|
||||
oneShotCliRun?: boolean;
|
||||
};
|
||||
|
||||
export type OpenClawPluginToolFactory = (
|
||||
|
||||
Reference in New Issue
Block a user