From ddacb7ba39d482cee9f0fd5dc065187130ea8dab Mon Sep 17 00:00:00 2001 From: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Date: Sat, 13 Jun 2026 07:14:54 -0700 Subject: [PATCH] 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 head 64fe82c24c0b5293463e5f12c5b3fb60de2c47b0. - Required merge gates passed before the squash merge. Prepared head SHA: 64fe82c24c0b5293463e5f12c5b3fb60de2c47b0 Review: 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> --- extensions/memory-core/index.ts | 2 + .../src/memory-tool-manager-mock.ts | 19 +- .../src/memory/qmd-manager.test.ts | 53 +++ .../memory-core/src/memory/qmd-manager.ts | 5 + .../memory-core/src/tools.citations.test.ts | 43 ++ extensions/memory-core/src/tools.shared.ts | 1 + .../memory-core/src/tools.test-helpers.ts | 2 + extensions/memory-core/src/tools.test.ts | 54 +++ extensions/memory-core/src/tools.ts | 400 ++++++++++-------- src/agents/agent-tools.ts | 7 + src/agents/cli-runner/types.ts | 2 + src/agents/command/attempt-execution.ts | 2 + src/agents/command/types.ts | 2 + src/agents/embedded-agent-runner/compact.ts | 3 +- .../embedded-agent-runner/compact.types.ts | 2 + .../embedded-agent-runner/run/attempt.ts | 1 + .../embedded-agent-runner/run/params.ts | 2 + src/agents/openclaw-tools.plugin-context.ts | 6 + src/agents/openclaw-tools.ts | 5 + src/commands/agent-via-gateway.test.ts | 2 + src/commands/agent-via-gateway.ts | 1 + src/plugins/tool-types.ts | 5 + 22 files changed, 436 insertions(+), 183 deletions(-) diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index 6e62e199c790..12168b83f923 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -29,6 +29,7 @@ type MemoryToolOptions = { agentId?: string; agentSessionKey?: string; sandboxed?: boolean; + oneShotCliRun?: boolean; }; let memoryToolsModulePromise: Promise | undefined; @@ -154,6 +155,7 @@ function resolveMemoryToolOptions(ctx: OpenClawPluginToolContext): MemoryToolOpt agentId: ctx.agentId, agentSessionKey: ctx.sessionKey, sandboxed: ctx.sandboxed, + oneShotCliRun: ctx.oneShotCliRun, }; } diff --git a/extensions/memory-core/src/memory-tool-manager-mock.ts b/extensions/memory-core/src/memory-tool-manager-mock.ts index 73a7265423c6..77ac340ba380 100644 --- a/extensions/memory-core/src/memory-tool-manager-mock.ts +++ b/extensions/memory-core/src/memory-tool-manager-mock.ts @@ -26,7 +26,7 @@ let workspaceDir = "/workspace"; let customStatus: Record | 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); } diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 156ac8023f3d..8a9d55cba684 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -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(); + 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((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, diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 69e59b75ac98..213cfae0a000 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -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}`, ); diff --git a/extensions/memory-core/src/tools.citations.test.ts b/extensions/memory-core/src/tools.citations.test.ts index 4f60a1c06bb7..6a8147afd64e 100644 --- a/extensions/memory-core/src/tools.citations.test.ts +++ b/extensions/memory-core/src/tools.citations.test.ts @@ -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"); diff --git a/extensions/memory-core/src/tools.shared.ts b/extensions/memory-core/src/tools.shared.ts index 1d33588bb2ae..8f1283265820 100644 --- a/extensions/memory-core/src/tools.shared.ts +++ b/extensions/memory-core/src/tools.shared.ts @@ -20,6 +20,7 @@ type MemoryToolOptions = { getConfig?: () => OpenClawConfig | undefined; agentId?: string; agentSessionKey?: string; + oneShotCliRun?: boolean; }; let memoryToolRuntimePromise: Promise | null = null; diff --git a/extensions/memory-core/src/tools.test-helpers.ts b/extensions/memory-core/src/tools.test-helpers.ts index fe8c120864c4..be1eb9771894 100644 --- a/extensions/memory-core/src/tools.test-helpers.ts +++ b/extensions/memory-core/src/tools.test-helpers.ts @@ -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"); diff --git a/extensions/memory-core/src/tools.test.ts b/extensions/memory-core/src/tools.test.ts index 79d071f1107e..f0862ebd5230 100644 --- a/extensions/memory-core/src/tools.test.ts +++ b/extensions/memory-core/src/tools.test.ts @@ -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 () => { diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index 946804935c7a..603e967fc86e 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -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>; +type ActiveMemoryManagerContext = Extract; 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, +): Promise { + for (const manager of managers) { + try { + await manager.close?.(); + } catch { + // Search results should not be hidden by best-effort transient cleanup. + } + } +} + async function runMemorySearchToolWithDeadline(params: { timeoutMs: number; run: (signal: AbortSignal) => Promise; @@ -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 = []; - 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(); + 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 = []; + 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") { diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index d0b57655385a..c552319f235a 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -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, diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 8367514e7b1f..59000938914b 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -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. */ diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index 5e3ea49957c6..42e3a0ed297d 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -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, diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts index 6bffa977e432..396ed3b5ea3c 100644 --- a/src/agents/command/types.ts +++ b/src/agents/command/types.ts @@ -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. */ diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index ba316e1067bc..29245a447a30 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -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, diff --git a/src/agents/embedded-agent-runner/compact.types.ts b/src/agents/embedded-agent-runner/compact.types.ts index f5957ec73bb3..ce2a98955256 100644 --- a/src/agents/embedded-agent-runner/compact.types.ts +++ b/src/agents/embedded-agent-runner/compact.types.ts @@ -102,6 +102,8 @@ export type CompactEmbeddedAgentSessionParams = { }) => void | Promise; /** 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 = { diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index f7f6fef4f446..10ca3108d370 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -1239,6 +1239,7 @@ export async function runEmbeddedAttempt( : undefined, sessionId: params.sessionId, runId: params.runId, + oneShotCliRun: params.oneShotCliRun, toolSearchCatalogRef, agentDir, cwd: effectiveCwd, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index 51689386ea3e..822a9c70a60b 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -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; }; diff --git a/src/agents/openclaw-tools.plugin-context.ts b/src/agents/openclaw-tools.plugin-context.ts index c021d5b9bc97..9f46ecd4042e 100644 --- a/src/agents/openclaw-tools.plugin-context.ts +++ b/src/agents/openclaw-tools.plugin-context.ts @@ -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, }; diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index bb37be7663a2..d5cc5a643062 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -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 diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index d843da973c77..183d8fe331c9 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -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); }); }); }); diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index 8a2bc7f0c2cf..853c4458c291 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -842,6 +842,7 @@ export async function agentCliCommand( replyAccountId: gatewayDispatchOpts.replyAccount, cleanupBundleMcpOnRunEnd: true, cleanupCliLiveSessionOnRunEnd: true, + oneShotCliRun: dispatchOpts.local === true, abortSignal: signalBridge.signal, }; try { diff --git a/src/plugins/tool-types.ts b/src/plugins/tool-types.ts index 80d61acc37b8..a120ba61d294 100644 --- a/src/plugins/tool-types.ts +++ b/src/plugins/tool-types.ts @@ -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 = (