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 64fe82c24c.
- Required merge gates passed before the squash merge.

Prepared head SHA: 64fe82c24c
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>
This commit is contained in:
Andy Ye
2026-06-13 07:14:54 -07:00
committed by GitHub
parent 762d8d8e64
commit ddacb7ba39
22 changed files with 436 additions and 183 deletions
+2
View File
@@ -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");
+54
View File
@@ -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
View File
@@ -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") {
+7
View File
@@ -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,
+2
View File
@@ -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. */
+2
View File
@@ -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,
+2
View File
@@ -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. */
+2 -1
View File
@@ -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,
};
+5
View File
@@ -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
+2
View File
@@ -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);
});
});
});
+1
View File
@@ -842,6 +842,7 @@ export async function agentCliCommand(
replyAccountId: gatewayDispatchOpts.replyAccount,
cleanupBundleMcpOnRunEnd: true,
cleanupCliLiveSessionOnRunEnd: true,
oneShotCliRun: dispatchOpts.local === true,
abortSignal: signalBridge.signal,
};
try {
+5
View File
@@ -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 = (