From 79960918a0f50d099ac2af1a03d52a7961d0c136 Mon Sep 17 00:00:00 2001 From: Gorkem Date: Sat, 12 Sep 2026 12:45:54 +0300 Subject: [PATCH 1/6] fix(reflection): read the departing transcript from the hook context on SQLite hosts OpenClaw hosts with SQLite session storage no longer expose a transcript file to plugins: the session entry carries no sessionFile, the legacy sessions directories are gone, and the command:new / command:reset hook context provides the departing session's recent messages instead (previousSessionMemory, one role-prefixed JSON record per line). The reflection hook kept resolving a session file, logged "missing session file after recovery" and returned, so no reflection ran on those hosts. Reflection now parses previousSessionMemory back into turns and runs the same pipeline on them first; the session-file lookup remains as the legacy fallback for hosts that still write files. Session-dir recovery also reads agents.entries (the object form of the agent config) alongside agents.list, matching the md-mirror path. Regressions: reflection runs from the hook transcript with no session file, the file path still works without a hook transcript, an empty or unavailable hook transcript falls back to the file lookup, and the search dirs enumerate agents.entries ids and workspaces. --- dist/index.js | 96 +++++++--- dist/src/session-recovery.js | 53 +++-- index.ts | 108 +++++++---- package.json | 2 +- scripts/ci-test-manifest.mjs | 1 + src/session-recovery.ts | 47 +++-- test/reflection-hook-session-memory.test.mjs | 191 +++++++++++++++++++ 7 files changed, 398 insertions(+), 100 deletions(-) create mode 100644 test/reflection-hook-session-memory.test.mjs diff --git a/dist/index.js b/dist/index.js index 1b265f451..29644a207 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1038,6 +1038,36 @@ function summarizeRecentConversationMessages(messages, messageCount, format = "t } return formatConversationTranscript(recent); } +const SESSION_MEMORY_RECORD_RE = /^(user|assistant): (".*")$/; +/** + * Hosts on SQLite session storage no longer expose a transcript file to plugins; + * the command:new / command:reset hook context carries the departing session's + * recent messages instead (`previousSessionMemory`, one `role: ""` + * record per line). Parse those records back into turns so the same reflection + * pipeline runs on either host generation. + */ +function conversationFromHookSessionMemory(memory, messageCount, format = "tagged") { + if (!memory || typeof memory !== "object") + return null; + const record = memory; + if (record.status !== "available" || typeof record.content !== "string") + return null; + const messages = []; + for (const line of record.content.split("\n")) { + const matched = line.match(SESSION_MEMORY_RECORD_RE); + if (!matched) + continue; + try { + const text = JSON.parse(matched[2]); + if (typeof text === "string") + messages.push({ role: matched[1], content: text }); + } + catch { + // a malformed record is skipped; the remaining lines still count + } + } + return summarizeRecentConversationMessages(messages, messageCount, format); +} async function readSessionConversationForReflection(filePath, messageCount, format = "tagged") { try { const lines = (await readFile(filePath, "utf-8")).trim().split("\n"); @@ -4613,39 +4643,47 @@ const memoryLanceDBProPlugin = { pruneReflectionSessionState(); const workspaceDir = resolveWorkspaceDirFromContext(context); api.logger.info(`memory-reflection: command:${action} hook start; sessionKey=${sessionKey || "(none)"}; source=${commandSource || "(unknown)"}; sessionId=${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}`); - if (!currentSessionFile || currentSessionFile.includes(".reset.")) { - const searchDirs = resolveReflectionSessionSearchDirs({ - context, - cfg, - workspaceDir, - currentSessionFile, - sourceAgentId, - }); - api.logger.info(`memory-reflection: command:${action} session recovery start for session ${currentSessionId}; initial=${currentSessionFile || "(none)"}; dirs=${searchDirs.join(" | ") || "(none)"}`); - for (const sessionsDir of searchDirs) { - const recovered = await findPreviousSessionFile(sessionsDir, currentSessionFile, currentSessionId); - if (recovered) { - api.logger.info(`memory-reflection: command:${action} recovered session file ${recovered} from ${sessionsDir}`); - currentSessionFile = recovered; - break; + // Hosts with SQLite session storage hand the departing transcript to the + // hook itself; the session-file lookup below is the legacy path. + let conversation = conversationFromHookSessionMemory(context.previousSessionMemory, reflectionMessageCount); + if (conversation) { + api.logger.info(`memory-reflection: command:${action} using the hook-provided transcript for session ${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}`); + } + else { + if (!currentSessionFile || currentSessionFile.includes(".reset.")) { + const searchDirs = resolveReflectionSessionSearchDirs({ + context, + cfg, + workspaceDir, + currentSessionFile, + sourceAgentId, + }); + api.logger.info(`memory-reflection: command:${action} session recovery start for session ${currentSessionId}; initial=${currentSessionFile || "(none)"}; dirs=${searchDirs.join(" | ") || "(none)"}`); + for (const sessionsDir of searchDirs) { + const recovered = await findPreviousSessionFile(sessionsDir, currentSessionFile, currentSessionId); + if (recovered) { + api.logger.info(`memory-reflection: command:${action} recovered session file ${recovered} from ${sessionsDir}`); + currentSessionFile = recovered; + break; + } } } + if (!currentSessionFile) { + const searchDirs = resolveReflectionSessionSearchDirs({ + context, + cfg, + workspaceDir, + currentSessionFile, + sourceAgentId, + }); + api.logger.warn(`memory-reflection: command:${action} missing session file after recovery for session ${currentSessionId}; dirs=${searchDirs.join(" | ") || "(none)"}`); + await rememberEmptyReflectionEvent("missing-session-file"); + return; + } + conversation = await readSessionConversationWithResetFallback(currentSessionFile, reflectionMessageCount); } - if (!currentSessionFile) { - const searchDirs = resolveReflectionSessionSearchDirs({ - context, - cfg, - workspaceDir, - currentSessionFile, - sourceAgentId, - }); - api.logger.warn(`memory-reflection: command:${action} missing session file after recovery for session ${currentSessionId}; dirs=${searchDirs.join(" | ") || "(none)"}`); - await rememberEmptyReflectionEvent("missing-session-file"); - return; - } - const conversation = await readSessionConversationWithResetFallback(currentSessionFile, reflectionMessageCount); if (!conversation) { - api.logger.warn(`memory-reflection: command:${action} conversation empty/unusable for session ${currentSessionId}; file=${currentSessionFile}`); + api.logger.warn(`memory-reflection: command:${action} conversation empty/unusable for session ${currentSessionId}; file=${currentSessionFile || "(none)"}`); await rememberEmptyReflectionEvent("empty-conversation"); return; } diff --git a/dist/src/session-recovery.js b/dist/src/session-recovery.js index ee34a3b24..89f21d0af 100644 --- a/dist/src/session-recovery.js +++ b/dist/src/session-recovery.js @@ -29,27 +29,46 @@ function deriveOpenClawHomeFromSessionFilePath(sessionFilePath) { const home = matched[1].trim(); return home.length ? home : undefined; } -function listConfiguredAgentIds(cfg) { +/** + * Agent definitions come as `agents.list` (an array of `{id, workspace}`) on + * older hosts and as `agents.entries` (an object keyed by agent id) on newer + * ones; both shapes are read so neither generation loses its sessions dirs. + */ +function listConfiguredAgents(cfg) { try { const root = cfg; const agents = root.agents; + const out = []; const list = agents?.list; - if (!Array.isArray(list)) - return []; - const ids = []; - for (const item of list) { - if (!item || typeof item !== "object") - continue; - const id = asNonEmptyString(item.id); - if (id) - ids.push(id); + if (Array.isArray(list)) { + for (const item of list) { + if (!item || typeof item !== "object") + continue; + const record = item; + out.push({ id: asNonEmptyString(record.id), workspace: asNonEmptyString(record.workspace) }); + } + } + const entries = agents?.entries; + if (entries && typeof entries === "object" && !Array.isArray(entries)) { + for (const [key, item] of Object.entries(entries)) { + const record = item && typeof item === "object" ? item : {}; + out.push({ id: asNonEmptyString(record.id) ?? asNonEmptyString(key), workspace: asNonEmptyString(record.workspace) }); + } } - return ids; + return out; } catch { return []; } } +function listConfiguredAgentIds(cfg) { + const ids = []; + for (const agent of listConfiguredAgents(cfg)) { + if (agent.id && !ids.includes(agent.id)) + ids.push(agent.id); + } + return ids; +} export function resolveReflectionSessionSearchDirs(params) { const out = []; const seen = new Set(); @@ -103,15 +122,9 @@ export function resolveReflectionSessionSearchDirs(params) { const defaultWorkspace = asNonEmptyString(defaults?.workspace); if (defaultWorkspace) addHome(openclawHomes, deriveOpenClawHomeFromWorkspacePath(defaultWorkspace)); - const list = agents?.list; - if (Array.isArray(list)) { - for (const item of list) { - if (!item || typeof item !== "object") - continue; - const workspace = asNonEmptyString(item.workspace); - if (workspace) - addHome(openclawHomes, deriveOpenClawHomeFromWorkspacePath(workspace)); - } + for (const agent of listConfiguredAgents(params.cfg)) { + if (agent.workspace) + addHome(openclawHomes, deriveOpenClawHomeFromWorkspacePath(agent.workspace)); } } catch { diff --git a/index.ts b/index.ts index d9d306167..1d5739fa2 100644 --- a/index.ts +++ b/index.ts @@ -1515,6 +1515,37 @@ function summarizeRecentConversationMessages( return formatConversationTranscript(recent); } +const SESSION_MEMORY_RECORD_RE = /^(user|assistant): (".*")$/; + +/** + * Hosts on SQLite session storage no longer expose a transcript file to plugins; + * the command:new / command:reset hook context carries the departing session's + * recent messages instead (`previousSessionMemory`, one `role: ""` + * record per line). Parse those records back into turns so the same reflection + * pipeline runs on either host generation. + */ +function conversationFromHookSessionMemory( + memory: unknown, + messageCount: number, + format: ConversationTranscriptFormat = "tagged", +): string | null { + if (!memory || typeof memory !== "object") return null; + const record = memory as Record; + if (record.status !== "available" || typeof record.content !== "string") return null; + const messages: Array<{ role: string; content: string }> = []; + for (const line of record.content.split("\n")) { + const matched = line.match(SESSION_MEMORY_RECORD_RE); + if (!matched) continue; + try { + const text = JSON.parse(matched[2]); + if (typeof text === "string") messages.push({ role: matched[1], content: text }); + } catch { + // a malformed record is skipped; the remaining lines still count + } + } + return summarizeRecentConversationMessages(messages, messageCount, format); +} + async function readSessionConversationForReflection(filePath: string, messageCount: number, format: ConversationTranscriptFormat = "tagged"): Promise { try { const lines = (await readFile(filePath, "utf-8")).trim().split("\n"); @@ -5834,48 +5865,57 @@ const memoryLanceDBProPlugin = { `memory-reflection: command:${action} hook start; sessionKey=${sessionKey || "(none)"}; source=${commandSource || "(unknown)"}; sessionId=${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}` ); - if (!currentSessionFile || currentSessionFile.includes(".reset.")) { - const searchDirs = resolveReflectionSessionSearchDirs({ - context, - cfg, - workspaceDir, - currentSessionFile, - sourceAgentId, - }); + // Hosts with SQLite session storage hand the departing transcript to the + // hook itself; the session-file lookup below is the legacy path. + let conversation = conversationFromHookSessionMemory(context.previousSessionMemory, reflectionMessageCount); + if (conversation) { api.logger.info( - `memory-reflection: command:${action} session recovery start for session ${currentSessionId}; initial=${currentSessionFile || "(none)"}; dirs=${searchDirs.join(" | ") || "(none)"}` + `memory-reflection: command:${action} using the hook-provided transcript for session ${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}` ); - for (const sessionsDir of searchDirs) { - const recovered = await findPreviousSessionFile(sessionsDir, currentSessionFile, currentSessionId); - if (recovered) { - api.logger.info( - `memory-reflection: command:${action} recovered session file ${recovered} from ${sessionsDir}` - ); - currentSessionFile = recovered; - break; + } else { + if (!currentSessionFile || currentSessionFile.includes(".reset.")) { + const searchDirs = resolveReflectionSessionSearchDirs({ + context, + cfg, + workspaceDir, + currentSessionFile, + sourceAgentId, + }); + api.logger.info( + `memory-reflection: command:${action} session recovery start for session ${currentSessionId}; initial=${currentSessionFile || "(none)"}; dirs=${searchDirs.join(" | ") || "(none)"}` + ); + for (const sessionsDir of searchDirs) { + const recovered = await findPreviousSessionFile(sessionsDir, currentSessionFile, currentSessionId); + if (recovered) { + api.logger.info( + `memory-reflection: command:${action} recovered session file ${recovered} from ${sessionsDir}` + ); + currentSessionFile = recovered; + break; + } } } - } - if (!currentSessionFile) { - const searchDirs = resolveReflectionSessionSearchDirs({ - context, - cfg, - workspaceDir, - currentSessionFile, - sourceAgentId, - }); - api.logger.warn( - `memory-reflection: command:${action} missing session file after recovery for session ${currentSessionId}; dirs=${searchDirs.join(" | ") || "(none)"}` - ); - await rememberEmptyReflectionEvent("missing-session-file"); - return; - } + if (!currentSessionFile) { + const searchDirs = resolveReflectionSessionSearchDirs({ + context, + cfg, + workspaceDir, + currentSessionFile, + sourceAgentId, + }); + api.logger.warn( + `memory-reflection: command:${action} missing session file after recovery for session ${currentSessionId}; dirs=${searchDirs.join(" | ") || "(none)"}` + ); + await rememberEmptyReflectionEvent("missing-session-file"); + return; + } - const conversation = await readSessionConversationWithResetFallback(currentSessionFile, reflectionMessageCount); + conversation = await readSessionConversationWithResetFallback(currentSessionFile, reflectionMessageCount); + } if (!conversation) { api.logger.warn( - `memory-reflection: command:${action} conversation empty/unusable for session ${currentSessionId}; file=${currentSessionFile}` + `memory-reflection: command:${action} conversation empty/unusable for session ${currentSessionId}; file=${currentSessionFile || "(none)"}` ); await rememberEmptyReflectionEvent("empty-conversation"); return; diff --git a/package.json b/package.json index c406c8c9e..c1bf9dc43 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "skills/**/*.md" ], "scripts": { - "test": "node test/embedder-error-hints.test.mjs && node --test test/embedder-max-input-chars.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/extraction-prompt-structural-noise.test.mjs && node test/i18n-memory-triggers.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/reflection-unattributed-session-read.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node --test test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node --test test/smart-extractor-noise-gating.test.mjs && node test/memory-capability-runtime.test.mjs && node --test test/startup-health-diagnostics.test.mjs && node test/corpus-indexer.test.mjs && node --test test/regex-fallback-bulk-store.test.mjs && node test/plugin-manifest-regression.mjs && node --test test/dreaming-engine.test.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-update-metadata-refresh.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs && node --test test/command-reflection-guard.test.mjs && node --test test/tier1-counters.test.mjs && node --test test/startup-check-timeout.test.mjs && node --test test/memory-subsession-prompt-hooks.test.mjs && node --test test/read-consistency-interval.test.mjs && node --test test/reflection-distiller-hook-skip.test.mjs && node --test test/register-scope-dedup.test.mjs && node --test test/raw-run-distiller-hooks.test.mjs && node --test test/autocapture-watermark-reset.test.mjs && node --test test/autocapture-internal-session-guard.test.mjs && node --test test/memory-categories-storage-map.test.mjs && node --test test/delete-invalidate-reflection-caches.test.mjs && node --test test/reflection-mapped-rows-admission.test.mjs && node --test test/smart-metadata-source-classification.test.mjs && node --test test/reflection-embed-transient-retry.test.mjs && node --test test/scope-owner-leak-hardening.test.mjs && node --test test/isOwnedByAgent.test.mjs && node --test test/typed-array-vector-fetch.test.mjs && node --test test/extraction-grounding-register.test.mjs && node test/grounding-rejudge.test.mjs && node --test test/reverse-map-legacy-category.test.mjs && node --test test/reflection-mapped-category-stamping.test.mjs && node --test test/memory-upgrader-category-normalization.test.mjs && node --test test/autocapture-fallback-gating.test.mjs && node --test test/prompt-architecture.test.mjs && node test/extraction-category-rubric.test.mjs && node --test test/admission-control-batch-utility.test.mjs && node --test test/smart-extractor-batch-admission.test.mjs && node --test test/admission-control-prompt-shape.test.mjs && node --test test/smart-extractor-merge-accounting.test.mjs && node --test test/admission-utility-veto.test.mjs && node --test test/cli-subcommand-attachment.test.mjs && node --test test/admission-lane-model-affinity.test.mjs && node --test test/admission-model-resolution.test.mjs && node --test test/admission-controller-standalone.test.mjs && node --test test/smart-extractor-admission-controller-injection.test.mjs && node --test test/admission-without-smart-extraction.test.mjs && node --test test/llm-thinklevel.test.mjs && node --test test/memory-id-prefix-resolution.test.mjs && node --test test/manual-store-supersede.test.mjs && node --test test/extraction-transcript-speaker-tags.test.mjs && node --test test/session-compressor.test.mjs && node --test test/reflection-derived-cache-invalidation.test.mjs && node --test test/reflection-tagged-input.test.mjs && node --test test/reflection-mapped-uniform-pipeline.test.mjs && node --test test/llm-host-transport.test.mjs && node --test test/llm-host-transport-composition.test.mjs && node --test test/admission-control-host-transport.test.mjs && node --test test/llm-transport-credential-hygiene.test.mjs && node --test test/memory-consolidate.test.mjs && node --test test/memory-consolidate-cost-gate.test.mjs && node --test test/memory-consolidate-two-phase-apply.test.mjs && node --test test/memory-consolidate-admission-independence.test.mjs && node --test test/memory-consolidate-polish.test.mjs && node --test test/consolidate-cli-settled-persistence.test.mjs && node --test test/consolidate-cli-apply-exit-status.test.mjs && node --test test/invalidated-rows-visibility.test.mjs && node --test test/store-excludeinactive-default.test.mjs && node --test test/manual-echo-guard.test.mjs", + "test": "node test/embedder-error-hints.test.mjs && node --test test/embedder-max-input-chars.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/extraction-prompt-structural-noise.test.mjs && node test/i18n-memory-triggers.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/reflection-unattributed-session-read.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node --test test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node --test test/smart-extractor-noise-gating.test.mjs && node test/memory-capability-runtime.test.mjs && node --test test/startup-health-diagnostics.test.mjs && node test/corpus-indexer.test.mjs && node --test test/regex-fallback-bulk-store.test.mjs && node test/plugin-manifest-regression.mjs && node --test test/dreaming-engine.test.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-update-metadata-refresh.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs && node --test test/command-reflection-guard.test.mjs && node --test test/tier1-counters.test.mjs && node --test test/startup-check-timeout.test.mjs && node --test test/memory-subsession-prompt-hooks.test.mjs && node --test test/read-consistency-interval.test.mjs && node --test test/reflection-distiller-hook-skip.test.mjs && node --test test/register-scope-dedup.test.mjs && node --test test/raw-run-distiller-hooks.test.mjs && node --test test/autocapture-watermark-reset.test.mjs && node --test test/autocapture-internal-session-guard.test.mjs && node --test test/memory-categories-storage-map.test.mjs && node --test test/delete-invalidate-reflection-caches.test.mjs && node --test test/reflection-mapped-rows-admission.test.mjs && node --test test/smart-metadata-source-classification.test.mjs && node --test test/reflection-embed-transient-retry.test.mjs && node --test test/scope-owner-leak-hardening.test.mjs && node --test test/isOwnedByAgent.test.mjs && node --test test/typed-array-vector-fetch.test.mjs && node --test test/extraction-grounding-register.test.mjs && node test/grounding-rejudge.test.mjs && node --test test/reverse-map-legacy-category.test.mjs && node --test test/reflection-mapped-category-stamping.test.mjs && node --test test/memory-upgrader-category-normalization.test.mjs && node --test test/autocapture-fallback-gating.test.mjs && node --test test/prompt-architecture.test.mjs && node test/extraction-category-rubric.test.mjs && node --test test/admission-control-batch-utility.test.mjs && node --test test/smart-extractor-batch-admission.test.mjs && node --test test/admission-control-prompt-shape.test.mjs && node --test test/smart-extractor-merge-accounting.test.mjs && node --test test/admission-utility-veto.test.mjs && node --test test/cli-subcommand-attachment.test.mjs && node --test test/admission-lane-model-affinity.test.mjs && node --test test/admission-model-resolution.test.mjs && node --test test/admission-controller-standalone.test.mjs && node --test test/smart-extractor-admission-controller-injection.test.mjs && node --test test/admission-without-smart-extraction.test.mjs && node --test test/llm-thinklevel.test.mjs && node --test test/memory-id-prefix-resolution.test.mjs && node --test test/manual-store-supersede.test.mjs && node --test test/extraction-transcript-speaker-tags.test.mjs && node --test test/session-compressor.test.mjs && node --test test/reflection-derived-cache-invalidation.test.mjs && node --test test/reflection-tagged-input.test.mjs && node --test test/reflection-mapped-uniform-pipeline.test.mjs && node --test test/llm-host-transport.test.mjs && node --test test/llm-host-transport-composition.test.mjs && node --test test/admission-control-host-transport.test.mjs && node --test test/llm-transport-credential-hygiene.test.mjs && node --test test/memory-consolidate.test.mjs && node --test test/memory-consolidate-cost-gate.test.mjs && node --test test/memory-consolidate-two-phase-apply.test.mjs && node --test test/memory-consolidate-admission-independence.test.mjs && node --test test/memory-consolidate-polish.test.mjs && node --test test/consolidate-cli-settled-persistence.test.mjs && node --test test/consolidate-cli-apply-exit-status.test.mjs && node --test test/invalidated-rows-visibility.test.mjs && node --test test/store-excludeinactive-default.test.mjs && node --test test/manual-echo-guard.test.mjs && node --test test/reflection-hook-session-memory.test.mjs", "test:cli-smoke": "node scripts/run-ci-tests.mjs --group cli-smoke", "test:core-regression": "node scripts/run-ci-tests.mjs --group core-regression", "test:storage-and-schema": "node scripts/run-ci-tests.mjs --group storage-and-schema", diff --git a/scripts/ci-test-manifest.mjs b/scripts/ci-test-manifest.mjs index e58e038d1..58e9ad477 100644 --- a/scripts/ci-test-manifest.mjs +++ b/scripts/ci-test-manifest.mjs @@ -99,6 +99,7 @@ export const CI_TEST_MANIFEST = [ // Issue #492 agentId validation tests { group: "core-regression", runner: "node", file: "test/agentid-validation.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/command-reflection-guard.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/reflection-hook-session-memory.test.mjs", args: ["--test"] }, // Tier 1 memory counter fix { group: "core-regression", runner: "node", file: "test/tier1-counters.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/memory-subsession-prompt-hooks.test.mjs", args: ["--test"] }, diff --git a/src/session-recovery.ts b/src/session-recovery.ts index 4750305f3..47deb385c 100644 --- a/src/session-recovery.ts +++ b/src/session-recovery.ts @@ -29,25 +29,45 @@ function deriveOpenClawHomeFromSessionFilePath(sessionFilePath: string): string return home.length ? home : undefined; } -function listConfiguredAgentIds(cfg: unknown): string[] { +/** + * Agent definitions come as `agents.list` (an array of `{id, workspace}`) on + * older hosts and as `agents.entries` (an object keyed by agent id) on newer + * ones; both shapes are read so neither generation loses its sessions dirs. + */ +function listConfiguredAgents(cfg: unknown): Array<{ id?: string; workspace?: string }> { try { const root = cfg as Record; const agents = root.agents as Record | undefined; + const out: Array<{ id?: string; workspace?: string }> = []; const list = agents?.list as unknown; - if (!Array.isArray(list)) return []; - - const ids: string[] = []; - for (const item of list) { - if (!item || typeof item !== "object") continue; - const id = asNonEmptyString((item as Record).id); - if (id) ids.push(id); + if (Array.isArray(list)) { + for (const item of list) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + out.push({ id: asNonEmptyString(record.id), workspace: asNonEmptyString(record.workspace) }); + } + } + const entries = agents?.entries as unknown; + if (entries && typeof entries === "object" && !Array.isArray(entries)) { + for (const [key, item] of Object.entries(entries as Record)) { + const record = item && typeof item === "object" ? (item as Record) : {}; + out.push({ id: asNonEmptyString(record.id) ?? asNonEmptyString(key), workspace: asNonEmptyString(record.workspace) }); + } } - return ids; + return out; } catch { return []; } } +function listConfiguredAgentIds(cfg: unknown): string[] { + const ids: string[] = []; + for (const agent of listConfiguredAgents(cfg)) { + if (agent.id && !ids.includes(agent.id)) ids.push(agent.id); + } + return ids; +} + export function resolveReflectionSessionSearchDirs(params: { context: Record; cfg: unknown; @@ -104,13 +124,8 @@ export function resolveReflectionSessionSearchDirs(params: { const defaultWorkspace = asNonEmptyString(defaults?.workspace); if (defaultWorkspace) addHome(openclawHomes, deriveOpenClawHomeFromWorkspacePath(defaultWorkspace)); - const list = agents?.list as unknown; - if (Array.isArray(list)) { - for (const item of list) { - if (!item || typeof item !== "object") continue; - const workspace = asNonEmptyString((item as Record).workspace); - if (workspace) addHome(openclawHomes, deriveOpenClawHomeFromWorkspacePath(workspace)); - } + for (const agent of listConfiguredAgents(params.cfg)) { + if (agent.workspace) addHome(openclawHomes, deriveOpenClawHomeFromWorkspacePath(agent.workspace)); } } catch { // ignore diff --git a/test/reflection-hook-session-memory.test.mjs b/test/reflection-hook-session-memory.test.mjs new file mode 100644 index 000000000..a05f36eb7 --- /dev/null +++ b/test/reflection-hook-session-memory.test.mjs @@ -0,0 +1,191 @@ +/** + * reflection-hook-session-memory.test.mjs + * + * Hosts with SQLite session storage no longer expose a transcript file to + * plugins; the command:new / command:reset hook context carries the departing + * session's recent messages as `previousSessionMemory` instead. Reflection must + * run from that transcript first and keep the session-file lookup only as the + * legacy fallback. Fixtures are synthetic. + * + * Run: node --test test/reflection-hook-session-memory.test.mjs + */ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "os"; +import path from "path"; +import { fileURLToPath } from "node:url"; +import jitiFactory from "jiti"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const pluginSdkStubPath = path.resolve(testDir, "helpers", "openclaw-plugin-sdk-stub.mjs"); +const jiti = jitiFactory(import.meta.url, { + interopDefault: true, + alias: { "openclaw/plugin-sdk": pluginSdkStubPath }, +}); + +const pluginModule = jiti("../index.ts"); +const memoryLanceDBProPlugin = pluginModule.default || pluginModule; +const resetRegistration = pluginModule.resetRegistration ?? (() => {}); +const { resolveReflectionSessionSearchDirs } = jiti("../src/session-recovery.ts"); + +function createPluginApiHarness({ pluginConfig, resolveRoot }) { + const eventHandlers = new Map(); + const logs = []; + const api = { + pluginConfig, + resolvePath(target) { + if (typeof target !== "string") return target; + return path.isAbsolute(target) ? target : path.join(resolveRoot, target); + }, + logger: { + info(message) { logs.push(["info", String(message)]); }, + warn(message) { logs.push(["warn", String(message)]); }, + debug(message) { logs.push(["debug", String(message)]); }, + error(message) { logs.push(["error", String(message)]); }, + }, + registerTool() {}, + registerCli() {}, + registerService() {}, + on(eventName, handler, meta) { + const list = eventHandlers.get(eventName) || []; + list.push({ handler, meta }); + eventHandlers.set(eventName, list); + }, + registerHook(eventName, handler, opts) { + const list = eventHandlers.get(eventName) || []; + list.push({ handler, meta: opts }); + eventHandlers.set(eventName, list); + }, + }; + return { api, eventHandlers, logs }; +} + +function makePluginConfig(workDir) { + return { + dbPath: path.join(workDir, "db"), + embedding: { apiKey: "test-api-key", dimensions: 4 }, + sessionStrategy: "memoryReflection", + smartExtraction: false, + autoCapture: false, + autoRecall: false, + selfImprovement: { enabled: false, beforeResetNote: false, ensureLearningFiles: false }, + memoryReflection: { excludeAgents: [] }, + }; +} + +const HOOK_TRANSCRIPT = [ + 'user: "Please remember that the rehearsal moved to Thursday."', + 'assistant: "Noted, the rehearsal is on Thursday now."', + 'user: "And the venue stays the same, the old town hall."', +].join("\n"); + +describe("runMemoryReflection reads the transcript the hook already carries", () => { + let workDir; + let originalCliBin; + + beforeEach(() => { + workDir = mkdtempSync(path.join(tmpdir(), "reflect-hook-memory-")); + resetRegistration(); + originalCliBin = process.env.OPENCLAW_CLI_BIN; + process.env.OPENCLAW_CLI_BIN = "/usr/bin/false"; + }); + + afterEach(() => { + resetRegistration(); + if (originalCliBin === undefined) delete process.env.OPENCLAW_CLI_BIN; + else process.env.OPENCLAW_CLI_BIN = originalCliBin; + rmSync(workDir, { recursive: true, force: true }); + }); + + async function invoke(context, sessionId) { + const pluginConfig = makePluginConfig(workDir); + const harness = createPluginApiHarness({ resolveRoot: workDir, pluginConfig }); + memoryLanceDBProPlugin.register(harness.api); + const hook = (harness.eventHandlers.get("command:new") || [])[0]; + assert.ok(hook, "the command:new reflection hook must be registered"); + const sessionKey = `agent:main:session:${sessionId}`; + await hook.handler( + { sessionKey, timestamp: 1000, action: "command:new", context: { cfg: pluginConfig, workspaceDir: workDir, ...context } }, + { sessionKey, agentId: "main" }, + ); + return harness.logs.map(([, message]) => message); + } + + it("reflects from previousSessionMemory when the host exposes no session file", async () => { + const logs = await invoke( + { + previousSessionEntry: { sessionId: "sqlite-session" }, + previousSessionMemory: { status: "available", content: HOOK_TRANSCRIPT, originClass: "agent" }, + }, + "sqlite-session", + ); + assert.ok( + logs.some((m) => m.includes("using the hook-provided transcript for session sqlite-session")), + `expected the hook transcript to be used; got ${JSON.stringify(logs)}`, + ); + assert.ok( + logs.some((m) => m.includes("reflection generation start for session sqlite-session")), + `reflection must reach the generation step; got ${JSON.stringify(logs)}`, + ); + assert.ok(!logs.some((m) => m.includes("missing session file after recovery")), "no file lookup failure may be logged"); + assert.ok(!logs.some((m) => m.includes("session recovery start")), "no file recovery may run when the hook carries the transcript"); + }); + + it("still reads the session file when the hook carries no transcript", async () => { + const sessionFile = path.join(workDir, "old-session.jsonl"); + writeFileSync( + sessionFile, + [ + JSON.stringify({ type: "message", message: { role: "user", content: "Please remember the old session." } }), + JSON.stringify({ type: "message", message: { role: "assistant", content: "I will reflect on the old session." } }), + ].join("\n") + "\n", + "utf-8", + ); + const logs = await invoke({ previousSessionEntry: { sessionId: "old-session", sessionFile } }, "old-session"); + assert.ok(logs.some((m) => m.includes("reflection generation start for session old-session")), `got ${JSON.stringify(logs)}`); + assert.ok(!logs.some((m) => m.includes("using the hook-provided transcript")), "the legacy file path must not claim a hook transcript"); + }); + + it("falls back to the file lookup when the hook transcript is empty or unavailable", async () => { + const logs = await invoke( + { + previousSessionEntry: { sessionId: "empty-hook" }, + previousSessionMemory: { status: "available", content: null, originClass: "agent" }, + }, + "empty-hook", + ); + assert.ok(logs.some((m) => m.includes("missing session file after recovery for session empty-hook")), `got ${JSON.stringify(logs)}`); + assert.ok(!logs.some((m) => m.includes("reflection generation start")), "nothing to reflect on"); + + const unavailable = await invoke( + { + previousSessionEntry: { sessionId: "unavailable-hook" }, + previousSessionMemory: { status: "unavailable", reason: "capture failed" }, + }, + "unavailable-hook", + ); + assert.ok(unavailable.some((m) => m.includes("missing session file after recovery for session unavailable-hook")), `got ${JSON.stringify(unavailable)}`); + }); +}); + +describe("resolveReflectionSessionSearchDirs reads both agent config shapes", () => { + it("enumerates agents.entries ids and workspaces like agents.list", () => { + const entriesDirs = resolveReflectionSessionSearchDirs({ + context: {}, + cfg: { agents: { entries: { alpha: { workspace: "/srv/openclaw/workspace" }, beta: {} } } }, + workspaceDir: "/srv/openclaw/workspace-alpha", + sourceAgentId: "alpha", + }); + assert.ok(entriesDirs.includes("/srv/openclaw/agents/alpha/sessions"), JSON.stringify(entriesDirs)); + assert.ok(entriesDirs.includes("/srv/openclaw/agents/beta/sessions"), JSON.stringify(entriesDirs)); + + const listDirs = resolveReflectionSessionSearchDirs({ + context: {}, + cfg: { agents: { list: [{ id: "gamma", workspace: "/srv/openclaw/workspace" }] } }, + workspaceDir: "/srv/openclaw/workspace-gamma", + sourceAgentId: "gamma", + }); + assert.ok(listDirs.includes("/srv/openclaw/agents/gamma/sessions"), JSON.stringify(listDirs)); + }); +}); From 33678b918cf2ade911c621655563ae43a0534e3f Mon Sep 17 00:00:00 2001 From: Gorkem Date: Sat, 12 Sep 2026 18:48:13 +0300 Subject: [PATCH 2/6] fix(reflection): run the distiller through runEmbeddedAgent and a headless CLI exec turn The host renamed api.runtime.agent.runEmbeddedPiAgent to runEmbeddedAgent and removed the alias, so the embedded distiller layer stopped resolving a runner; the extensionAPI.js import layer no longer exists on those hosts; and the CLI layer hardcoded `openclaw agent --local`, which the host now refuses while a gateway owns the state directory. Every reflection fell through to the minimal fallback pointer, and the 400-char diagnostic clip showed only startup banners instead of the refusal. The runner lookup now accepts runEmbeddedAgent first and the legacy name second (SDK layer and extensionAPI.js layer alike); the distiller run is marked sessionPersistence: "detached" so it never lands in the session store; the CLI fallback drives `openclaw agent exec` (prompt over stdin, workspace via --cwd, resolved provider/model via --model) and retries the legacy --local shape only when the host rejects the exec arguments; and stderr diagnostics keep the tail with state-migration banners and ANSI codes removed. Regressions: runner-name preference and legacy acceptance, the detached embedded run through runEmbeddedAgent, the exec and legacy argument shapes, the legacy retry decision, tail-preserving diagnostic clipping, and the exec JSON envelope (payloads then final). --- dist/index.js | 206 +++++++++----- index.ts | 251 +++++++++++++----- package.json | 2 +- scripts/ci-test-manifest.mjs | 1 + .../reflection-runner-embedded-agent.test.mjs | 200 ++++++++++++++ 5 files changed, 524 insertions(+), 136 deletions(-) create mode 100644 test/reflection-runner-embedded-agent.test.mjs diff --git a/dist/index.js b/dist/index.js index 29644a207..02f93d84d 100644 --- a/dist/index.js +++ b/dist/index.js @@ -474,19 +474,28 @@ export function getExtensionApiImportSpecifiers(options = {}) { return [...new Set(specifiers.filter(Boolean))]; } /** - * Layer 1: 新 SDK API — api.runtime.agent.runEmbeddedPiAgent (4.22+) + * Layer 1: SDK API — api.runtime.agent.runEmbeddedAgent (hosts before the + * rename expose runEmbeddedPiAgent; both names are accepted) * Layer 2: 舊 extensionAPI.js dynamic import(4.24-4.26 SDK 仍保留) * Layer 3: CLI fallback * * 遷移自 Bug 2(Issue #606):原本只使用 Layer 2,現改為 Try-New-First。 */ +const EMBEDDED_RUNNER_EXPORT_NAMES = ["runEmbeddedAgent", "runEmbeddedPiAgent"]; +export function resolveEmbeddedRunnerExportName(candidate) { + if (!candidate || typeof candidate !== "object") + return undefined; + const record = candidate; + return EMBEDDED_RUNNER_EXPORT_NAMES.find((name) => typeof record[name] === "function"); +} // eslint-disable-next-line import/export export async function loadEmbeddedPiRunner(api) { // Layer 1: 嘗試新 SDK API (with circuit breaker) if (!isLayer1CircuitOpen()) { const newApi = (api.runtime?.agent); - if (typeof newApi?.runEmbeddedPiAgent === "function") { - const runner = newApi.runEmbeddedPiAgent.bind(newApi); + const runnerName = resolveEmbeddedRunnerExportName(newApi); + if (newApi && runnerName) { + const runner = newApi[runnerName].bind(newApi); // Bug 2 fix: 將 Layer 1 結果寫入 cache,避免後續並發呼叫時 Layer 2 覆蓋掉 Layer 1 embeddedPiRunnerPromise ??= Promise.resolve(runner); return embeddedPiRunnerPromise; @@ -499,10 +508,10 @@ export async function loadEmbeddedPiRunner(api) { for (const specifier of getExtensionApiImportSpecifiers()) { try { const mod = await import(specifier); - const runner = mod.runEmbeddedPiAgent; - if (typeof runner === "function") - return runner; - importErrors.push(`${specifier}: runEmbeddedPiAgent export not found`); + const runnerName = resolveEmbeddedRunnerExportName(mod); + if (runnerName) + return mod[runnerName]; + importErrors.push(`${specifier}: runEmbeddedAgent export not found`); } catch (err) { importErrors.push(`${specifier}: ${err instanceof Error ? err.message : String(err)}`); @@ -528,6 +537,21 @@ function clipDiagnostic(text, maxLen = 400) { return oneLine; return `${oneLine.slice(0, maxLen - 3)}...`; } +const ANSI_ESCAPE_RE = /\u001b\[[0-9;]*[A-Za-z]/g; +const CLI_STARTUP_NOISE_LINE_RE = /^\s*\[state-migrations\]/; +function stripAnsi(text) { + return text.replace(ANSI_ESCAPE_RE, ""); +} +// CLI failures print the reason last, after startup banners; keep the tail. +export function clipDiagnosticTail(text, maxLen = 400) { + const lines = stripAnsi(text) + .split(/\r?\n/) + .filter((line) => line.trim().length > 0 && !CLI_STARTUP_NOISE_LINE_RE.test(line)); + const oneLine = lines.join(" ").replace(/\s+/g, " ").trim(); + if (oneLine.length <= maxLen) + return oneLine; + return `...${oneLine.slice(oneLine.length - (maxLen - 3))}`; +} function withTimeout(promise, timeoutMs, label) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -572,7 +596,7 @@ function extractJsonObjectFromOutput(stdout) { } throw new Error(`unable to parse JSON from CLI output: ${clipDiagnostic(trimmed, 280)}`); } -function extractReflectionTextFromCliResult(resultObj) { +export function extractReflectionTextFromCliResult(resultObj) { const result = resultObj.result; const payloads = Array.isArray(resultObj.payloads) ? resultObj.payloads @@ -581,34 +605,60 @@ function extractReflectionTextFromCliResult(resultObj) { : []; const firstWithText = payloads.find((p) => p && typeof p === "object" && typeof p.text === "string" && p.text.trim().length); const text = typeof firstWithText?.text === "string" ? firstWithText.text.trim() : ""; - return text || null; + if (text) + return text; + const finalText = typeof resultObj.final === "string" ? resultObj.final.trim() : ""; + return finalText || null; } -async function runReflectionViaCli(params) { - const cliBin = process.env.OPENCLAW_CLI_BIN?.trim() || "openclaw"; - const outerTimeoutMs = Math.max(params.timeoutMs + 5000, 15000); - const agentTimeoutSec = Math.max(1, Math.ceil(params.timeoutMs / 1000)); - const sessionId = `memory-reflection-cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +// Hosts without `agent exec` reject its argument shape before doing any work. +const LEGACY_CLI_SHAPE_REJECTION_RE = /unknown command|does not recognize|unknown option|too many arguments/i; +export function buildReflectionCliArgs(params) { + if (params.mode === "legacy-local") { + return [ + "agent", + "--local", + "--agent", + params.agentId, + "--message", + params.prompt, + "--json", + "--thinking", + params.thinkLevel, + "--timeout", + String(params.agentTimeoutSec), + "--session-id", + params.sessionId, + ]; + } const args = [ "agent", - "--local", - "--agent", - params.agentId, - "--message", - params.prompt, + "exec", + "--message-file", + "-", + "--cwd", + params.workspaceDir, "--json", "--thinking", params.thinkLevel, "--timeout", - String(agentTimeoutSec), - "--session-id", - sessionId, + String(params.agentTimeoutSec), ]; + if (params.modelRef) + args.push("--model", params.modelRef); + return args; +} +export function shouldRetryReflectionCliAsLegacyLocal(run) { + if (run.timedOut || run.signal || run.code === 0) + return false; + return LEGACY_CLI_SHAPE_REJECTION_RE.test(stripAnsi(run.stderr)); +} +async function spawnReflectionCli(params) { return await new Promise((resolve, reject) => { - const spawnCommand = buildReflectionCliSpawnCommand(cliBin, args); + const spawnCommand = buildReflectionCliSpawnCommand(params.cliBin, params.args); const child = spawn(spawnCommand.command, spawnCommand.args, { - cwd: params.workspaceDir, + cwd: params.cwd, env: { ...process.env, NO_COLOR: "1" }, - stdio: ["ignore", "pipe", "pipe"], + stdio: [params.stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; @@ -618,7 +668,7 @@ async function runReflectionViaCli(params) { timedOut = true; child.kill("SIGTERM"); setTimeout(() => child.kill("SIGKILL"), 1500).unref(); - }, outerTimeoutMs); + }, params.outerTimeoutMs); child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk) => { stdout += chunk; @@ -627,45 +677,67 @@ async function runReflectionViaCli(params) { child.stderr.on("data", (chunk) => { stderr += chunk; }); + if (params.stdinText !== undefined && child.stdin) { + child.stdin.on("error", () => { }); + child.stdin.end(params.stdinText); + } child.once("error", (err) => { if (settled) return; settled = true; clearTimeout(timer); - reject(new Error(`spawn ${cliBin} failed: ${err.message}`)); + reject(new Error(`spawn ${params.cliBin} failed: ${err.message}`)); }); child.once("close", (code, signal) => { if (settled) return; settled = true; clearTimeout(timer); - if (timedOut) { - reject(new Error(`${cliBin} timed out after ${outerTimeoutMs}ms`)); - return; - } - if (signal) { - reject(new Error(`${cliBin} exited by signal ${signal}. stderr=${clipDiagnostic(stderr)}`)); - return; - } - if (code !== 0) { - reject(new Error(`${cliBin} exited with code ${code}. stderr=${clipDiagnostic(stderr)}`)); - return; - } - try { - const parsed = extractJsonObjectFromOutput(stdout); - const text = extractReflectionTextFromCliResult(parsed); - if (!text) { - reject(new Error(`CLI JSON returned no text payload. stdout=${clipDiagnostic(stdout)}`)); - return; - } - resolve(text); - } - catch (err) { - reject(err instanceof Error ? err : new Error(String(err))); - } + resolve({ stdout, stderr, code, signal, timedOut }); }); }); } +async function runReflectionViaCli(params) { + const cliBin = process.env.OPENCLAW_CLI_BIN?.trim() || "openclaw"; + const outerTimeoutMs = Math.max(params.timeoutMs + 5000, 15000); + const agentTimeoutSec = Math.max(1, Math.ceil(params.timeoutMs / 1000)); + const sessionId = `memory-reflection-cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const argParams = { + agentId: params.agentId, + prompt: params.prompt, + workspaceDir: params.workspaceDir, + thinkLevel: params.thinkLevel, + agentTimeoutSec, + sessionId, + modelRef: params.modelRef, + }; + let run = await spawnReflectionCli({ + cliBin, + args: buildReflectionCliArgs({ ...argParams, mode: "exec" }), + cwd: params.workspaceDir, + outerTimeoutMs, + stdinText: params.prompt, + }); + if (shouldRetryReflectionCliAsLegacyLocal(run)) { + run = await spawnReflectionCli({ + cliBin, + args: buildReflectionCliArgs({ ...argParams, mode: "legacy-local" }), + cwd: params.workspaceDir, + outerTimeoutMs, + }); + } + if (run.timedOut) + throw new Error(`${cliBin} timed out after ${outerTimeoutMs}ms`); + if (run.signal) + throw new Error(`${cliBin} exited by signal ${run.signal}. stderr=${clipDiagnosticTail(run.stderr)}`); + if (run.code !== 0) + throw new Error(`${cliBin} exited with code ${run.code}. stderr=${clipDiagnosticTail(run.stderr)}`); + const parsed = extractJsonObjectFromOutput(run.stdout); + const text = extractReflectionTextFromCliResult(parsed); + if (!text) + throw new Error(`CLI JSON returned no text payload. stdout=${clipDiagnostic(run.stdout)}`); + return text; +} export function buildReflectionCliSpawnCommand(cliBin, args, platform = process.platform, comSpec = process.env.ComSpec?.trim()) { if (platform === "win32") { return { @@ -1309,6 +1381,20 @@ function buildReflectionFallbackText() { "- Investigate why embedded reflection generation failed before trusting any next-run delta.", ].join("\n"); } +// Model resolution chain: explicit param > agent-specific primary model ref > global llm.model. +// Provider: parsed from the ref (e.g. "minimax/MiniMax-M2.7") > inferred from baseURL +// (inferProviderFromBaseURL uses .endsWith(".suffix") to prevent subdomain spoofing). +function resolveReflectionModelTarget(params) { + const cfg = params.cfg; + const llmConfig = cfg?.llm; + const modelRefFromConfig = llmConfig?.model; + const modelRef = params.model + ?? resolveAgentPrimaryModelRef(params.cfg, params.agentId) + ?? (typeof modelRefFromConfig === "string" ? modelRefFromConfig : undefined); + const split = modelRef ? splitProviderModel(modelRef) : { provider: undefined, model: undefined }; + const provider = split.provider ?? inferProviderFromBaseURL(llmConfig?.baseURL); + return { provider, model: split.model }; +} const REFLECTION_RUN_SLOTS = Symbol.for("openclaw.memory-lancedb-pro.reflection-run-slots"); const getReflectionRunSlotState = () => { const g = globalThis; @@ -1363,6 +1449,8 @@ async function generateReflectionTextUnbounded(params) { else params.logger?.info?.(message); }; + const { provider, model } = resolveReflectionModelTarget(params); + const cliModelRef = provider && model ? `${provider}/${model}` : undefined; try { const result = await runWithReflectionTransientRetryOnce({ scope: "reflection", @@ -1371,23 +1459,12 @@ async function generateReflectionTextUnbounded(params) { onLog: onRetryLog, execute: async () => { const runEmbeddedPiAgent = await loadEmbeddedPiRunner(params.api); - const cfg = params.cfg; - const llmConfig = cfg?.llm; - const modelRefFromConfig = llmConfig?.model; - // Model resolution chain: agent-specific primary model ref > global llm.model fallback. - // The typeof guard ensures a non-string value (e.g. number) does not reach splitProviderModel as-is. - const modelRef = params.model - ?? resolveAgentPrimaryModelRef(params.cfg, params.agentId) - ?? (typeof modelRefFromConfig === "string" ? modelRefFromConfig : undefined); - // Provider resolution chain: parsed from modelRef (e.g. "minimax/MiniMax-M2.7") > inferred from baseURL. - // inferProviderFromBaseURL uses .endsWith(".suffix") to prevent subdomain spoofing. - const split = modelRef ? splitProviderModel(modelRef) : { provider: undefined, model: undefined }; - const provider = split.provider ?? inferProviderFromBaseURL(llmConfig?.baseURL); - const model = split.model; const embeddedTimeoutMs = Math.max(params.timeoutMs + 5000, 15000); return await withTimeout(runEmbeddedPiAgent({ sessionId: `reflection-${Date.now()}`, sessionKey: `temp:memory-reflection:${params.agentId}`, + // The distiller run is throwaway: keep it out of the host session store. + sessionPersistence: "detached", agentId: params.agentId, sessionFile: tempSessionFile, workspaceDir: params.workspaceDir, @@ -1447,6 +1524,7 @@ async function generateReflectionTextUnbounded(params) { workspaceDir: params.workspaceDir, timeoutMs: params.timeoutMs, thinkLevel: params.thinkLevel, + modelRef: cliModelRef, }), }); } diff --git a/index.ts b/index.ts index 1d5739fa2..6c6cf5324 100644 --- a/index.ts +++ b/index.ts @@ -896,19 +896,30 @@ export function getExtensionApiImportSpecifiers( } /** - * Layer 1: 新 SDK API — api.runtime.agent.runEmbeddedPiAgent (4.22+) + * Layer 1: SDK API — api.runtime.agent.runEmbeddedAgent (hosts before the + * rename expose runEmbeddedPiAgent; both names are accepted) * Layer 2: 舊 extensionAPI.js dynamic import(4.24-4.26 SDK 仍保留) * Layer 3: CLI fallback * * 遷移自 Bug 2(Issue #606):原本只使用 Layer 2,現改為 Try-New-First。 */ +const EMBEDDED_RUNNER_EXPORT_NAMES = ["runEmbeddedAgent", "runEmbeddedPiAgent"] as const; +type EmbeddedRunnerExportName = (typeof EMBEDDED_RUNNER_EXPORT_NAMES)[number]; + +export function resolveEmbeddedRunnerExportName(candidate: unknown): EmbeddedRunnerExportName | undefined { + if (!candidate || typeof candidate !== "object") return undefined; + const record = candidate as Record; + return EMBEDDED_RUNNER_EXPORT_NAMES.find((name) => typeof record[name] === "function"); +} + // eslint-disable-next-line import/export export async function loadEmbeddedPiRunner(api: OpenClawPluginApi): Promise { // Layer 1: 嘗試新 SDK API (with circuit breaker) if (!isLayer1CircuitOpen()) { const newApi = ((api as unknown as { runtime?: { agent?: Record } }).runtime?.agent); - if (typeof newApi?.runEmbeddedPiAgent === "function") { - const runner = newApi.runEmbeddedPiAgent.bind(newApi); + const runnerName = resolveEmbeddedRunnerExportName(newApi); + if (newApi && runnerName) { + const runner = (newApi[runnerName] as EmbeddedPiRunner).bind(newApi); // Bug 2 fix: 將 Layer 1 結果寫入 cache,避免後續並發呼叫時 Layer 2 覆蓋掉 Layer 1 embeddedPiRunnerPromise ??= Promise.resolve(runner as EmbeddedPiRunner); return embeddedPiRunnerPromise; @@ -922,9 +933,9 @@ export async function loadEmbeddedPiRunner(api: OpenClawPluginApi): Promise).runEmbeddedPiAgent; - if (typeof runner === "function") return runner as EmbeddedPiRunner; - importErrors.push(`${specifier}: runEmbeddedPiAgent export not found`); + const runnerName = resolveEmbeddedRunnerExportName(mod); + if (runnerName) return (mod as Record)[runnerName] as EmbeddedPiRunner; + importErrors.push(`${specifier}: runEmbeddedAgent export not found`); } catch (err) { importErrors.push(`${specifier}: ${err instanceof Error ? err.message : String(err)}`); } @@ -952,6 +963,23 @@ function clipDiagnostic(text: string, maxLen = 400): string { return `${oneLine.slice(0, maxLen - 3)}...`; } +const ANSI_ESCAPE_RE = /\u001b\[[0-9;]*[A-Za-z]/g; +const CLI_STARTUP_NOISE_LINE_RE = /^\s*\[state-migrations\]/; + +function stripAnsi(text: string): string { + return text.replace(ANSI_ESCAPE_RE, ""); +} + +// CLI failures print the reason last, after startup banners; keep the tail. +export function clipDiagnosticTail(text: string, maxLen = 400): string { + const lines = stripAnsi(text) + .split(/\r?\n/) + .filter((line) => line.trim().length > 0 && !CLI_STARTUP_NOISE_LINE_RE.test(line)); + const oneLine = lines.join(" ").replace(/\s+/g, " ").trim(); + if (oneLine.length <= maxLen) return oneLine; + return `...${oneLine.slice(oneLine.length - (maxLen - 3))}`; +} + function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -1001,7 +1029,7 @@ function extractJsonObjectFromOutput(stdout: string): Record { throw new Error(`unable to parse JSON from CLI output: ${clipDiagnostic(trimmed, 280)}`); } -function extractReflectionTextFromCliResult(resultObj: Record): string | null { +export function extractReflectionTextFromCliResult(resultObj: Record): string | null { const result = resultObj.result as Record | undefined; const payloads = Array.isArray(resultObj.payloads) ? resultObj.payloads @@ -1012,43 +1040,88 @@ function extractReflectionTextFromCliResult(resultObj: Record): (p) => p && typeof p === "object" && typeof (p as Record).text === "string" && ((p as Record).text as string).trim().length ) as Record | undefined; const text = typeof firstWithText?.text === "string" ? firstWithText.text.trim() : ""; - return text || null; + if (text) return text; + const finalText = typeof resultObj.final === "string" ? resultObj.final.trim() : ""; + return finalText || null; } -async function runReflectionViaCli(params: { - prompt: string; +type ReflectionCliMode = "exec" | "legacy-local"; + +type ReflectionCliRun = { + stdout: string; + stderr: string; + code: number | null; + signal: NodeJS.Signals | null; + timedOut: boolean; +}; + +// Hosts without `agent exec` reject its argument shape before doing any work. +const LEGACY_CLI_SHAPE_REJECTION_RE = /unknown command|does not recognize|unknown option|too many arguments/i; + +export function buildReflectionCliArgs(params: { + mode: ReflectionCliMode; agentId: string; + prompt: string; workspaceDir: string; - timeoutMs: number; thinkLevel: ReflectionThinkLevel; -}): Promise { - const cliBin = process.env.OPENCLAW_CLI_BIN?.trim() || "openclaw"; - const outerTimeoutMs = Math.max(params.timeoutMs + 5000, 15000); - const agentTimeoutSec = Math.max(1, Math.ceil(params.timeoutMs / 1000)); - const sessionId = `memory-reflection-cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - + agentTimeoutSec: number; + sessionId: string; + modelRef?: string; +}): string[] { + if (params.mode === "legacy-local") { + return [ + "agent", + "--local", + "--agent", + params.agentId, + "--message", + params.prompt, + "--json", + "--thinking", + params.thinkLevel, + "--timeout", + String(params.agentTimeoutSec), + "--session-id", + params.sessionId, + ]; + } const args = [ "agent", - "--local", - "--agent", - params.agentId, - "--message", - params.prompt, + "exec", + "--message-file", + "-", + "--cwd", + params.workspaceDir, "--json", "--thinking", params.thinkLevel, "--timeout", - String(agentTimeoutSec), - "--session-id", - sessionId, + String(params.agentTimeoutSec), ]; + if (params.modelRef) args.push("--model", params.modelRef); + return args; +} + +export function shouldRetryReflectionCliAsLegacyLocal( + run: Pick, +): boolean { + if (run.timedOut || run.signal || run.code === 0) return false; + return LEGACY_CLI_SHAPE_REJECTION_RE.test(stripAnsi(run.stderr)); +} - return await new Promise((resolve, reject) => { - const spawnCommand = buildReflectionCliSpawnCommand(cliBin, args); +async function spawnReflectionCli(params: { + cliBin: string; + args: string[]; + cwd: string; + outerTimeoutMs: number; + stdinText?: string; +}): Promise { + return await new Promise((resolve, reject) => { + const spawnCommand = buildReflectionCliSpawnCommand(params.cliBin, params.args); const child = spawn(spawnCommand.command, spawnCommand.args, { - cwd: params.workspaceDir, + cwd: params.cwd, env: { ...process.env, NO_COLOR: "1" }, - stdio: ["ignore", "pipe", "pipe"], + stdio: [params.stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"], }); let stdout = ""; @@ -1060,7 +1133,7 @@ async function runReflectionViaCli(params: { timedOut = true; child.kill("SIGTERM"); setTimeout(() => child.kill("SIGKILL"), 1500).unref(); - }, outerTimeoutMs); + }, params.outerTimeoutMs); child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk) => { @@ -1072,44 +1145,73 @@ async function runReflectionViaCli(params: { stderr += chunk; }); + if (params.stdinText !== undefined && child.stdin) { + child.stdin.on("error", () => { }); + child.stdin.end(params.stdinText); + } + child.once("error", (err) => { if (settled) return; settled = true; clearTimeout(timer); - reject(new Error(`spawn ${cliBin} failed: ${err.message}`)); + reject(new Error(`spawn ${params.cliBin} failed: ${err.message}`)); }); child.once("close", (code, signal) => { if (settled) return; settled = true; clearTimeout(timer); + resolve({ stdout, stderr, code, signal, timedOut }); + }); + }); +} - if (timedOut) { - reject(new Error(`${cliBin} timed out after ${outerTimeoutMs}ms`)); - return; - } - if (signal) { - reject(new Error(`${cliBin} exited by signal ${signal}. stderr=${clipDiagnostic(stderr)}`)); - return; - } - if (code !== 0) { - reject(new Error(`${cliBin} exited with code ${code}. stderr=${clipDiagnostic(stderr)}`)); - return; - } +async function runReflectionViaCli(params: { + prompt: string; + agentId: string; + workspaceDir: string; + timeoutMs: number; + thinkLevel: ReflectionThinkLevel; + modelRef?: string; +}): Promise { + const cliBin = process.env.OPENCLAW_CLI_BIN?.trim() || "openclaw"; + const outerTimeoutMs = Math.max(params.timeoutMs + 5000, 15000); + const agentTimeoutSec = Math.max(1, Math.ceil(params.timeoutMs / 1000)); + const sessionId = `memory-reflection-cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const argParams = { + agentId: params.agentId, + prompt: params.prompt, + workspaceDir: params.workspaceDir, + thinkLevel: params.thinkLevel, + agentTimeoutSec, + sessionId, + modelRef: params.modelRef, + }; - try { - const parsed = extractJsonObjectFromOutput(stdout); - const text = extractReflectionTextFromCliResult(parsed); - if (!text) { - reject(new Error(`CLI JSON returned no text payload. stdout=${clipDiagnostic(stdout)}`)); - return; - } - resolve(text); - } catch (err) { - reject(err instanceof Error ? err : new Error(String(err))); - } - }); + let run = await spawnReflectionCli({ + cliBin, + args: buildReflectionCliArgs({ ...argParams, mode: "exec" }), + cwd: params.workspaceDir, + outerTimeoutMs, + stdinText: params.prompt, }); + if (shouldRetryReflectionCliAsLegacyLocal(run)) { + run = await spawnReflectionCli({ + cliBin, + args: buildReflectionCliArgs({ ...argParams, mode: "legacy-local" }), + cwd: params.workspaceDir, + outerTimeoutMs, + }); + } + + if (run.timedOut) throw new Error(`${cliBin} timed out after ${outerTimeoutMs}ms`); + if (run.signal) throw new Error(`${cliBin} exited by signal ${run.signal}. stderr=${clipDiagnosticTail(run.stderr)}`); + if (run.code !== 0) throw new Error(`${cliBin} exited with code ${run.code}. stderr=${clipDiagnosticTail(run.stderr)}`); + + const parsed = extractJsonObjectFromOutput(run.stdout); + const text = extractReflectionTextFromCliResult(parsed); + if (!text) throw new Error(`CLI JSON returned no text payload. stdout=${clipDiagnostic(run.stdout)}`); + return text; } export function buildReflectionCliSpawnCommand( @@ -1821,6 +1923,24 @@ type GenerateReflectionTextResult = { runner: "embedded" | "cli" | "fallback"; }; +// Model resolution chain: explicit param > agent-specific primary model ref > global llm.model. +// Provider: parsed from the ref (e.g. "minimax/MiniMax-M2.7") > inferred from baseURL +// (inferProviderFromBaseURL uses .endsWith(".suffix") to prevent subdomain spoofing). +function resolveReflectionModelTarget( + params: Pick, +): { provider?: string; model?: string } { + const cfg = params.cfg as Record | undefined; + const llmConfig = cfg?.llm as Record | undefined; + const modelRefFromConfig = llmConfig?.model; + const modelRef = + params.model + ?? (resolveAgentPrimaryModelRef(params.cfg, params.agentId) as string | undefined) + ?? (typeof modelRefFromConfig === "string" ? modelRefFromConfig : undefined); + const split = modelRef ? splitProviderModel(modelRef) : { provider: undefined, model: undefined }; + const provider = split.provider ?? inferProviderFromBaseURL(llmConfig?.baseURL as string | undefined); + return { provider, model: split.model }; +} + type ReflectionRunSlotState = { active: number; waiters: Array<() => void> }; const REFLECTION_RUN_SLOTS = Symbol.for("openclaw.memory-lancedb-pro.reflection-run-slots"); @@ -1883,6 +2003,8 @@ async function generateReflectionTextUnbounded( if (level === "warn") params.logger?.warn?.(message); else params.logger?.info?.(message); }; + const { provider, model } = resolveReflectionModelTarget(params); + const cliModelRef = provider && model ? `${provider}/${model}` : undefined; try { const result: unknown = await runWithReflectionTransientRetryOnce({ @@ -1892,28 +2014,14 @@ async function generateReflectionTextUnbounded( onLog: onRetryLog, execute: async () => { const runEmbeddedPiAgent = await loadEmbeddedPiRunner(params.api); - const cfg = params.cfg as Record; - const llmConfig = cfg?.llm as Record | undefined; - const modelRefFromConfig = llmConfig?.model; - - // Model resolution chain: agent-specific primary model ref > global llm.model fallback. - // The typeof guard ensures a non-string value (e.g. number) does not reach splitProviderModel as-is. - const modelRef = - params.model - ?? (resolveAgentPrimaryModelRef(params.cfg, params.agentId) as string | undefined) - ?? (typeof modelRefFromConfig === "string" ? modelRefFromConfig : undefined); - - // Provider resolution chain: parsed from modelRef (e.g. "minimax/MiniMax-M2.7") > inferred from baseURL. - // inferProviderFromBaseURL uses .endsWith(".suffix") to prevent subdomain spoofing. - const split = modelRef ? splitProviderModel(modelRef) : { provider: undefined, model: undefined }; - const provider = split.provider ?? inferProviderFromBaseURL(llmConfig?.baseURL as string | undefined); - const model = split.model; const embeddedTimeoutMs = Math.max(params.timeoutMs + 5000, 15000); return await withTimeout( runEmbeddedPiAgent({ sessionId: `reflection-${Date.now()}`, sessionKey: `temp:memory-reflection:${params.agentId}`, + // The distiller run is throwaway: keep it out of the host session store. + sessionPersistence: "detached", agentId: params.agentId, sessionFile: tempSessionFile, workspaceDir: params.workspaceDir, @@ -1976,6 +2084,7 @@ async function generateReflectionTextUnbounded( workspaceDir: params.workspaceDir, timeoutMs: params.timeoutMs, thinkLevel: params.thinkLevel, + modelRef: cliModelRef, }), }); } catch (err) { diff --git a/package.json b/package.json index c1bf9dc43..6c305b66d 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "skills/**/*.md" ], "scripts": { - "test": "node test/embedder-error-hints.test.mjs && node --test test/embedder-max-input-chars.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/extraction-prompt-structural-noise.test.mjs && node test/i18n-memory-triggers.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/reflection-unattributed-session-read.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node --test test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node --test test/smart-extractor-noise-gating.test.mjs && node test/memory-capability-runtime.test.mjs && node --test test/startup-health-diagnostics.test.mjs && node test/corpus-indexer.test.mjs && node --test test/regex-fallback-bulk-store.test.mjs && node test/plugin-manifest-regression.mjs && node --test test/dreaming-engine.test.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-update-metadata-refresh.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs && node --test test/command-reflection-guard.test.mjs && node --test test/tier1-counters.test.mjs && node --test test/startup-check-timeout.test.mjs && node --test test/memory-subsession-prompt-hooks.test.mjs && node --test test/read-consistency-interval.test.mjs && node --test test/reflection-distiller-hook-skip.test.mjs && node --test test/register-scope-dedup.test.mjs && node --test test/raw-run-distiller-hooks.test.mjs && node --test test/autocapture-watermark-reset.test.mjs && node --test test/autocapture-internal-session-guard.test.mjs && node --test test/memory-categories-storage-map.test.mjs && node --test test/delete-invalidate-reflection-caches.test.mjs && node --test test/reflection-mapped-rows-admission.test.mjs && node --test test/smart-metadata-source-classification.test.mjs && node --test test/reflection-embed-transient-retry.test.mjs && node --test test/scope-owner-leak-hardening.test.mjs && node --test test/isOwnedByAgent.test.mjs && node --test test/typed-array-vector-fetch.test.mjs && node --test test/extraction-grounding-register.test.mjs && node test/grounding-rejudge.test.mjs && node --test test/reverse-map-legacy-category.test.mjs && node --test test/reflection-mapped-category-stamping.test.mjs && node --test test/memory-upgrader-category-normalization.test.mjs && node --test test/autocapture-fallback-gating.test.mjs && node --test test/prompt-architecture.test.mjs && node test/extraction-category-rubric.test.mjs && node --test test/admission-control-batch-utility.test.mjs && node --test test/smart-extractor-batch-admission.test.mjs && node --test test/admission-control-prompt-shape.test.mjs && node --test test/smart-extractor-merge-accounting.test.mjs && node --test test/admission-utility-veto.test.mjs && node --test test/cli-subcommand-attachment.test.mjs && node --test test/admission-lane-model-affinity.test.mjs && node --test test/admission-model-resolution.test.mjs && node --test test/admission-controller-standalone.test.mjs && node --test test/smart-extractor-admission-controller-injection.test.mjs && node --test test/admission-without-smart-extraction.test.mjs && node --test test/llm-thinklevel.test.mjs && node --test test/memory-id-prefix-resolution.test.mjs && node --test test/manual-store-supersede.test.mjs && node --test test/extraction-transcript-speaker-tags.test.mjs && node --test test/session-compressor.test.mjs && node --test test/reflection-derived-cache-invalidation.test.mjs && node --test test/reflection-tagged-input.test.mjs && node --test test/reflection-mapped-uniform-pipeline.test.mjs && node --test test/llm-host-transport.test.mjs && node --test test/llm-host-transport-composition.test.mjs && node --test test/admission-control-host-transport.test.mjs && node --test test/llm-transport-credential-hygiene.test.mjs && node --test test/memory-consolidate.test.mjs && node --test test/memory-consolidate-cost-gate.test.mjs && node --test test/memory-consolidate-two-phase-apply.test.mjs && node --test test/memory-consolidate-admission-independence.test.mjs && node --test test/memory-consolidate-polish.test.mjs && node --test test/consolidate-cli-settled-persistence.test.mjs && node --test test/consolidate-cli-apply-exit-status.test.mjs && node --test test/invalidated-rows-visibility.test.mjs && node --test test/store-excludeinactive-default.test.mjs && node --test test/manual-echo-guard.test.mjs && node --test test/reflection-hook-session-memory.test.mjs", + "test": "node test/embedder-error-hints.test.mjs && node --test test/embedder-max-input-chars.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/extraction-prompt-structural-noise.test.mjs && node test/i18n-memory-triggers.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/reflection-unattributed-session-read.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node --test test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node --test test/smart-extractor-noise-gating.test.mjs && node test/memory-capability-runtime.test.mjs && node --test test/startup-health-diagnostics.test.mjs && node test/corpus-indexer.test.mjs && node --test test/regex-fallback-bulk-store.test.mjs && node test/plugin-manifest-regression.mjs && node --test test/dreaming-engine.test.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-update-metadata-refresh.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs && node --test test/command-reflection-guard.test.mjs && node --test test/tier1-counters.test.mjs && node --test test/startup-check-timeout.test.mjs && node --test test/memory-subsession-prompt-hooks.test.mjs && node --test test/read-consistency-interval.test.mjs && node --test test/reflection-distiller-hook-skip.test.mjs && node --test test/register-scope-dedup.test.mjs && node --test test/raw-run-distiller-hooks.test.mjs && node --test test/autocapture-watermark-reset.test.mjs && node --test test/autocapture-internal-session-guard.test.mjs && node --test test/memory-categories-storage-map.test.mjs && node --test test/delete-invalidate-reflection-caches.test.mjs && node --test test/reflection-mapped-rows-admission.test.mjs && node --test test/smart-metadata-source-classification.test.mjs && node --test test/reflection-embed-transient-retry.test.mjs && node --test test/scope-owner-leak-hardening.test.mjs && node --test test/isOwnedByAgent.test.mjs && node --test test/typed-array-vector-fetch.test.mjs && node --test test/extraction-grounding-register.test.mjs && node test/grounding-rejudge.test.mjs && node --test test/reverse-map-legacy-category.test.mjs && node --test test/reflection-mapped-category-stamping.test.mjs && node --test test/memory-upgrader-category-normalization.test.mjs && node --test test/autocapture-fallback-gating.test.mjs && node --test test/prompt-architecture.test.mjs && node test/extraction-category-rubric.test.mjs && node --test test/admission-control-batch-utility.test.mjs && node --test test/smart-extractor-batch-admission.test.mjs && node --test test/admission-control-prompt-shape.test.mjs && node --test test/smart-extractor-merge-accounting.test.mjs && node --test test/admission-utility-veto.test.mjs && node --test test/cli-subcommand-attachment.test.mjs && node --test test/admission-lane-model-affinity.test.mjs && node --test test/admission-model-resolution.test.mjs && node --test test/admission-controller-standalone.test.mjs && node --test test/smart-extractor-admission-controller-injection.test.mjs && node --test test/admission-without-smart-extraction.test.mjs && node --test test/llm-thinklevel.test.mjs && node --test test/memory-id-prefix-resolution.test.mjs && node --test test/manual-store-supersede.test.mjs && node --test test/extraction-transcript-speaker-tags.test.mjs && node --test test/session-compressor.test.mjs && node --test test/reflection-derived-cache-invalidation.test.mjs && node --test test/reflection-tagged-input.test.mjs && node --test test/reflection-mapped-uniform-pipeline.test.mjs && node --test test/llm-host-transport.test.mjs && node --test test/llm-host-transport-composition.test.mjs && node --test test/admission-control-host-transport.test.mjs && node --test test/llm-transport-credential-hygiene.test.mjs && node --test test/memory-consolidate.test.mjs && node --test test/memory-consolidate-cost-gate.test.mjs && node --test test/memory-consolidate-two-phase-apply.test.mjs && node --test test/memory-consolidate-admission-independence.test.mjs && node --test test/memory-consolidate-polish.test.mjs && node --test test/consolidate-cli-settled-persistence.test.mjs && node --test test/consolidate-cli-apply-exit-status.test.mjs && node --test test/invalidated-rows-visibility.test.mjs && node --test test/store-excludeinactive-default.test.mjs && node --test test/manual-echo-guard.test.mjs && node --test test/reflection-hook-session-memory.test.mjs && node --test test/reflection-runner-embedded-agent.test.mjs", "test:cli-smoke": "node scripts/run-ci-tests.mjs --group cli-smoke", "test:core-regression": "node scripts/run-ci-tests.mjs --group core-regression", "test:storage-and-schema": "node scripts/run-ci-tests.mjs --group storage-and-schema", diff --git a/scripts/ci-test-manifest.mjs b/scripts/ci-test-manifest.mjs index 58e9ad477..0daed66e2 100644 --- a/scripts/ci-test-manifest.mjs +++ b/scripts/ci-test-manifest.mjs @@ -100,6 +100,7 @@ export const CI_TEST_MANIFEST = [ { group: "core-regression", runner: "node", file: "test/agentid-validation.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/command-reflection-guard.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/reflection-hook-session-memory.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/reflection-runner-embedded-agent.test.mjs", args: ["--test"] }, // Tier 1 memory counter fix { group: "core-regression", runner: "node", file: "test/tier1-counters.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/memory-subsession-prompt-hooks.test.mjs", args: ["--test"] }, diff --git a/test/reflection-runner-embedded-agent.test.mjs b/test/reflection-runner-embedded-agent.test.mjs new file mode 100644 index 000000000..6e4a620c1 --- /dev/null +++ b/test/reflection-runner-embedded-agent.test.mjs @@ -0,0 +1,200 @@ +/** + * reflection-runner-embedded-agent.test.mjs + * + * The host renamed api.runtime.agent.runEmbeddedPiAgent to runEmbeddedAgent + * and removed the alias; `openclaw agent --local` is refused while a gateway + * owns the state directory; and CLI startup banners pushed the real failure + * reason out of the clipped diagnostic. Reflection must pick the new runner + * name (still accepting the old one), keep the distiller run detached from the + * session store, drive the CLI fallback through `agent exec`, and report the + * tail of stderr. Fixtures are synthetic. + * + * Run: node --test test/reflection-runner-embedded-agent.test.mjs + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import jitiFactory from "jiti"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const pluginSdkStubPath = path.resolve(testDir, "helpers", "openclaw-plugin-sdk-stub.mjs"); + +function loadFreshIndex() { + const jiti = jitiFactory(import.meta.url, { + interopDefault: true, + moduleCache: false, + alias: { "openclaw/plugin-sdk": pluginSdkStubPath }, + }); + return jiti("../index.ts"); +} + +const { + resolveEmbeddedRunnerExportName, + buildReflectionCliArgs, + shouldRetryReflectionCliAsLegacyLocal, + clipDiagnosticTail, + extractReflectionTextFromCliResult, +} = loadFreshIndex(); + +const noop = async () => ({ payloads: [{ text: "noop" }] }); + +describe("embedded runner export resolution", () => { + it("prefers runEmbeddedAgent when the host exposes both names", () => { + const name = resolveEmbeddedRunnerExportName({ runEmbeddedAgent: noop, runEmbeddedPiAgent: noop }); + assert.equal(name, "runEmbeddedAgent"); + }); + + it("still accepts the legacy runEmbeddedPiAgent name", () => { + assert.equal(resolveEmbeddedRunnerExportName({ runEmbeddedPiAgent: noop }), "runEmbeddedPiAgent"); + }); + + it("returns undefined for hosts without a callable runner", () => { + assert.equal(resolveEmbeddedRunnerExportName({ runEmbeddedAgent: "not a function" }), undefined); + assert.equal(resolveEmbeddedRunnerExportName(undefined), undefined); + assert.equal(resolveEmbeddedRunnerExportName(null), undefined); + assert.equal(resolveEmbeddedRunnerExportName({}), undefined); + }); +}); + +describe("reflection distiller on a renamed-runner host", () => { + it("invokes runEmbeddedAgent with a detached, tool-free minimal model run", async () => { + const { generateReflectionText } = loadFreshIndex(); + let seenParams = null; + let legacyCalls = 0; + const api = { + runtime: { + agent: { + runEmbeddedAgent: async (params) => { + seenParams = params; + return { payloads: [{ text: "distilled reflection" }] }; + }, + runEmbeddedPiAgent: async () => { + legacyCalls += 1; + return { payloads: [{ text: "legacy" }] }; + }, + }, + }, + }; + + const result = await generateReflectionText({ + conversation: "user: the build is green\nassistant: noted", + maxInputChars: 1000, + cfg: { llm: { model: "openrouter/example/model-one" } }, + agentId: "agent-one", + workspaceDir: "/tmp", + timeoutMs: 2000, + thinkLevel: "off", + api, + }); + + assert.equal(result.runner, "embedded"); + assert.equal(result.text, "distilled reflection"); + assert.equal(legacyCalls, 0, "the legacy alias must not be called when the new name exists"); + assert.ok(seenParams, "runEmbeddedAgent must have been invoked"); + assert.equal(seenParams.sessionPersistence, "detached"); + assert.equal(seenParams.modelRun, true); + assert.equal(seenParams.promptMode, "minimal"); + assert.equal(seenParams.disableTools, true); + assert.equal(seenParams.provider, "openrouter"); + assert.equal(seenParams.model, "example/model-one"); + }); +}); + +describe("CLI fallback argument shape", () => { + const base = { + agentId: "agent-one", + prompt: "line one\nline two", + workspaceDir: "/tmp/workspace-one", + thinkLevel: "low", + agentTimeoutSec: 30, + sessionId: "memory-reflection-cli-1", + }; + + it("drives a headless exec turn and never asks for --local", () => { + const args = buildReflectionCliArgs({ ...base, mode: "exec", modelRef: "openrouter/example/model-one" }); + assert.deepEqual(args.slice(0, 2), ["agent", "exec"]); + assert.ok(!args.includes("--local"), `--local must be absent: ${JSON.stringify(args)}`); + assert.ok(!args.includes("--agent"), "exec has no agent selector"); + assert.ok(!args.includes(base.prompt), "the prompt travels over stdin, not argv"); + assert.deepEqual(args.slice(args.indexOf("--message-file"), args.indexOf("--message-file") + 2), ["--message-file", "-"]); + assert.deepEqual(args.slice(args.indexOf("--cwd"), args.indexOf("--cwd") + 2), ["--cwd", base.workspaceDir]); + assert.ok(args.includes("--json")); + assert.deepEqual(args.slice(args.indexOf("--thinking"), args.indexOf("--thinking") + 2), ["--thinking", "low"]); + assert.deepEqual(args.slice(args.indexOf("--timeout"), args.indexOf("--timeout") + 2), ["--timeout", "30"]); + assert.deepEqual(args.slice(args.indexOf("--model"), args.indexOf("--model") + 2), ["--model", "openrouter/example/model-one"]); + }); + + it("omits --model when no provider-qualified ref resolved", () => { + const args = buildReflectionCliArgs({ ...base, mode: "exec" }); + assert.ok(!args.includes("--model")); + }); + + it("keeps the legacy --local shape for hosts that predate agent exec", () => { + const args = buildReflectionCliArgs({ ...base, mode: "legacy-local" }); + assert.deepEqual(args, [ + "agent", + "--local", + "--agent", + "agent-one", + "--message", + base.prompt, + "--json", + "--thinking", + "low", + "--timeout", + "30", + "--session-id", + "memory-reflection-cli-1", + ]); + }); +}); + +describe("legacy retry decision", () => { + const run = (stderr, code = 1) => ({ stderr, code, signal: null, timedOut: false }); + + it("retries only when the host rejected the exec argument shape", () => { + assert.equal(shouldRetryReflectionCliAsLegacyLocal(run("error: unknown command 'exec'")), true); + assert.equal(shouldRetryReflectionCliAsLegacyLocal(run("error: too many arguments for 'agent'. Expected 0 arguments but got 1.")), true); + assert.equal(shouldRetryReflectionCliAsLegacyLocal(run('\u001b[31mOpenClaw does not recognize option "--message-file".\u001b[39m')), true); + }); + + it("does not retry runtime failures, successes, signals or timeouts", () => { + assert.equal(shouldRetryReflectionCliAsLegacyLocal(run("A Gateway is running for this state directory (pid 1, port 2).")), false); + assert.equal(shouldRetryReflectionCliAsLegacyLocal(run("error: unknown command 'exec'", 0)), false); + assert.equal(shouldRetryReflectionCliAsLegacyLocal({ stderr: "error: unknown command 'exec'", code: null, signal: "SIGTERM", timedOut: false }), false); + assert.equal(shouldRetryReflectionCliAsLegacyLocal({ stderr: "error: unknown command 'exec'", code: null, signal: null, timedOut: true }), false); + }); +}); + +describe("CLI diagnostic clipping", () => { + it("drops state-migration banners and ANSI noise, keeping the failure reason", () => { + const stderr = [ + "\u001b[33m[state-migrations]\u001b[39m legacy allowFrom file left in place: /tmp/one.json", + "[state-migrations] legacy allowFrom file left in place: /tmp/two.json", + "", + "A Gateway is running for this state directory (pid 1, port 2). Run without --local to use it.", + ].join("\n"); + const clipped = clipDiagnosticTail(stderr); + assert.ok(!clipped.includes("state-migrations"), clipped); + assert.ok(!clipped.includes("\u001b["), clipped); + assert.ok(clipped.includes("Run without --local to use it."), clipped); + }); + + it("keeps the tail when the text is longer than the budget", () => { + const filler = "banner ".repeat(200); + const clipped = clipDiagnosticTail(`${filler}final reason here`, 60); + assert.ok(clipped.startsWith("..."), clipped); + assert.ok(clipped.endsWith("final reason here"), clipped); + assert.equal(clipped.length, 60); + }); +}); + +describe("CLI result extraction", () => { + it("reads the exec envelope payloads and falls back to final", () => { + assert.equal(extractReflectionTextFromCliResult({ ok: true, payloads: [{ text: " from payloads " }], final: "from final" }), "from payloads"); + assert.equal(extractReflectionTextFromCliResult({ ok: true, payloads: [], final: " from final " }), "from final"); + assert.equal(extractReflectionTextFromCliResult({ result: { payloads: [{ text: "legacy envelope" }] } }), "legacy envelope"); + assert.equal(extractReflectionTextFromCliResult({ ok: true, payloads: [] }), null); + }); +}); From c48c14414e7a240fab9cb24a7abb03c7aa1eda0e Mon Sep 17 00:00:00 2001 From: Gorkem Date: Sat, 12 Sep 2026 19:10:33 +0300 Subject: [PATCH 3/6] fix(reflection): hand the transcript file only to the legacy embedded runner Current hosts run a plugin session-ownership check on every embedded run and treat a sessionFile that is not a session key as a foreign transcript ("Plugin session ownership checks require a SQLite transcript marker"), so the distiller's temporary jsonl path made runEmbeddedAgent refuse the run and reflection fell through to the CLI layer on every boundary. The legacy runEmbeddedPiAgent still reads its transcript from that path. The runner loader now records which export it resolved, and the distiller passes sessionFile only when the legacy runner is in use; the detached run on current hosts carries no transcript path at all. Regressions: the renamed-runner run sees no sessionFile; the legacy runner still receives a jsonl path. --- dist/index.js | 16 ++++++++-- index.ts | 20 ++++++++++-- .../reflection-runner-embedded-agent.test.mjs | 31 +++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/dist/index.js b/dist/index.js index 02f93d84d..6c4aade6a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -488,6 +488,15 @@ export function resolveEmbeddedRunnerExportName(candidate) { const record = candidate; return EMBEDDED_RUNNER_EXPORT_NAMES.find((name) => typeof record[name] === "function"); } +let embeddedRunnerExportName; +export function getEmbeddedRunnerExportName() { + return embeddedRunnerExportName; +} +// Legacy hosts read the distiller transcript from a file path; current hosts +// treat a non-key sessionFile as a foreign transcript and refuse the run. +function embeddedRunnerTakesSessionFile() { + return embeddedRunnerExportName !== "runEmbeddedAgent"; +} // eslint-disable-next-line import/export export async function loadEmbeddedPiRunner(api) { // Layer 1: 嘗試新 SDK API (with circuit breaker) @@ -495,6 +504,7 @@ export async function loadEmbeddedPiRunner(api) { const newApi = (api.runtime?.agent); const runnerName = resolveEmbeddedRunnerExportName(newApi); if (newApi && runnerName) { + embeddedRunnerExportName = runnerName; const runner = newApi[runnerName].bind(newApi); // Bug 2 fix: 將 Layer 1 結果寫入 cache,避免後續並發呼叫時 Layer 2 覆蓋掉 Layer 1 embeddedPiRunnerPromise ??= Promise.resolve(runner); @@ -509,8 +519,10 @@ export async function loadEmbeddedPiRunner(api) { try { const mod = await import(specifier); const runnerName = resolveEmbeddedRunnerExportName(mod); - if (runnerName) + if (runnerName) { + embeddedRunnerExportName = runnerName; return mod[runnerName]; + } importErrors.push(`${specifier}: runEmbeddedAgent export not found`); } catch (err) { @@ -1466,7 +1478,7 @@ async function generateReflectionTextUnbounded(params) { // The distiller run is throwaway: keep it out of the host session store. sessionPersistence: "detached", agentId: params.agentId, - sessionFile: tempSessionFile, + ...(embeddedRunnerTakesSessionFile() ? { sessionFile: tempSessionFile } : {}), workspaceDir: params.workspaceDir, config: params.cfg, prompt, diff --git a/index.ts b/index.ts index 6c6cf5324..558b2ec34 100644 --- a/index.ts +++ b/index.ts @@ -912,6 +912,18 @@ export function resolveEmbeddedRunnerExportName(candidate: unknown): EmbeddedRun return EMBEDDED_RUNNER_EXPORT_NAMES.find((name) => typeof record[name] === "function"); } +let embeddedRunnerExportName: EmbeddedRunnerExportName | undefined; + +export function getEmbeddedRunnerExportName(): EmbeddedRunnerExportName | undefined { + return embeddedRunnerExportName; +} + +// Legacy hosts read the distiller transcript from a file path; current hosts +// treat a non-key sessionFile as a foreign transcript and refuse the run. +function embeddedRunnerTakesSessionFile(): boolean { + return embeddedRunnerExportName !== "runEmbeddedAgent"; +} + // eslint-disable-next-line import/export export async function loadEmbeddedPiRunner(api: OpenClawPluginApi): Promise { // Layer 1: 嘗試新 SDK API (with circuit breaker) @@ -919,6 +931,7 @@ export async function loadEmbeddedPiRunner(api: OpenClawPluginApi): Promise } }).runtime?.agent); const runnerName = resolveEmbeddedRunnerExportName(newApi); if (newApi && runnerName) { + embeddedRunnerExportName = runnerName; const runner = (newApi[runnerName] as EmbeddedPiRunner).bind(newApi); // Bug 2 fix: 將 Layer 1 結果寫入 cache,避免後續並發呼叫時 Layer 2 覆蓋掉 Layer 1 embeddedPiRunnerPromise ??= Promise.resolve(runner as EmbeddedPiRunner); @@ -934,7 +947,10 @@ export async function loadEmbeddedPiRunner(api: OpenClawPluginApi): Promise)[runnerName] as EmbeddedPiRunner; + if (runnerName) { + embeddedRunnerExportName = runnerName; + return (mod as Record)[runnerName] as EmbeddedPiRunner; + } importErrors.push(`${specifier}: runEmbeddedAgent export not found`); } catch (err) { importErrors.push(`${specifier}: ${err instanceof Error ? err.message : String(err)}`); @@ -2023,7 +2039,7 @@ async function generateReflectionTextUnbounded( // The distiller run is throwaway: keep it out of the host session store. sessionPersistence: "detached", agentId: params.agentId, - sessionFile: tempSessionFile, + ...(embeddedRunnerTakesSessionFile() ? { sessionFile: tempSessionFile } : {}), workspaceDir: params.workspaceDir, config: params.cfg, prompt, diff --git a/test/reflection-runner-embedded-agent.test.mjs b/test/reflection-runner-embedded-agent.test.mjs index 6e4a620c1..e6a350ac1 100644 --- a/test/reflection-runner-embedded-agent.test.mjs +++ b/test/reflection-runner-embedded-agent.test.mjs @@ -98,6 +98,37 @@ describe("reflection distiller on a renamed-runner host", () => { assert.equal(seenParams.disableTools, true); assert.equal(seenParams.provider, "openrouter"); assert.equal(seenParams.model, "example/model-one"); + assert.equal(seenParams.sessionFile, undefined, "current hosts refuse a non-key sessionFile for plugin runs"); + }); + + it("still hands the legacy runner a transcript file path", async () => { + const { generateReflectionText } = loadFreshIndex(); + let seenParams = null; + const api = { + runtime: { + agent: { + runEmbeddedPiAgent: async (params) => { + seenParams = params; + return { payloads: [{ text: "legacy reflection" }] }; + }, + }, + }, + }; + + const result = await generateReflectionText({ + conversation: "user: the build is green\nassistant: noted", + maxInputChars: 1000, + cfg: {}, + agentId: "agent-one", + workspaceDir: "/tmp", + timeoutMs: 2000, + thinkLevel: "off", + api, + }); + + assert.equal(result.runner, "embedded"); + assert.equal(typeof seenParams.sessionFile, "string"); + assert.ok(seenParams.sessionFile.endsWith(".jsonl"), seenParams.sessionFile); }); }); From 8f4d4304f2c4fc90e6f501be4e8dfd35dabf5a43 Mon Sep 17 00:00:00 2001 From: Gorkem Date: Sat, 12 Sep 2026 22:53:58 +0300 Subject: [PATCH 4/6] fix(reflection): finish a transcript-less command hook from the typed before_reset messages The Gateway command path emits command:new before the departing transcript is captured, so the hook can arrive with neither previousSessionMemory nor a session file. Park such a boundary per session key and finish the same reflection pipeline from the typed before_reset hook, which core fires right after the command hooks on every path with the departing messages. A boundary reflects at most once; orphan or non-boundary before_reset events are ignored. --- dist/index.js | 60 +++++- index.ts | 63 +++++- package.json | 2 +- scripts/ci-test-manifest.mjs | 1 + ...eflection-before-reset-transcript.test.mjs | 203 ++++++++++++++++++ test/reflection-hook-session-memory.test.mjs | 4 +- 6 files changed, 320 insertions(+), 13 deletions(-) create mode 100644 test/reflection-before-reset-transcript.test.mjs diff --git a/dist/index.js b/dist/index.js index 6c4aade6a..6d7d591e9 100644 --- a/dist/index.js +++ b/dist/index.js @@ -4603,15 +4603,37 @@ const memoryLanceDBProPlugin = { return g[REFLECTION_SERIAL_GUARD]; }; // SERIAL_GUARD_COOLDOWN_MS moved to DEFAULT_SERIAL_GUARD_COOLDOWN_MS - const runMemoryReflection = async (event) => { + // A command:new / command:reset hook that finds neither a hook transcript nor a + // session file parks here; the typed before_reset hook, which core fires right + // after the command hooks on every command path, carries the departing messages + // and finishes the reflection from this entry. + const REFLECTION_PENDING_BEFORE_RESET_TTL_MS = 60_000; + const pendingBeforeResetReflections = new Map(); + const rememberPendingBeforeResetReflection = (key, entry) => { + const now = Date.now(); + for (const [pendingKey, pending] of pendingBeforeResetReflections) { + if (now - pending.at > REFLECTION_PENDING_BEFORE_RESET_TTL_MS) + pendingBeforeResetReflections.delete(pendingKey); + } + pendingBeforeResetReflections.set(key, { ...entry, at: now }); + }; + const takePendingBeforeResetReflection = (key) => { + const pending = pendingBeforeResetReflections.get(key); + if (!pending) + return undefined; + pendingBeforeResetReflections.delete(key); + return Date.now() - pending.at > REFLECTION_PENDING_BEFORE_RESET_TTL_MS ? undefined : pending; + }; + const runMemoryReflectionWith = async (event, options) => { const sessionKey = typeof event.sessionKey === "string" ? event.sessionKey : ""; const action = String(event?.action || "unknown"); + const resumedFromBeforeReset = options !== undefined && "beforeResetConversation" in options; // Validate sessionKey BEFORE dedup — invalid/empty keys must NOT pollute the dedup set if (!sessionKey) { // skip events without a valid sessionKey — they are not meaningful for reflection return; } - if (_dedupHookEvent("reflection", event)) + if (!resumedFromBeforeReset && _dedupHookEvent("reflection", event)) return; const context = (event.context || {}); const cfg = context.cfg; @@ -4735,8 +4757,13 @@ const memoryLanceDBProPlugin = { api.logger.info(`memory-reflection: command:${action} hook start; sessionKey=${sessionKey || "(none)"}; source=${commandSource || "(unknown)"}; sessionId=${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}`); // Hosts with SQLite session storage hand the departing transcript to the // hook itself; the session-file lookup below is the legacy path. - let conversation = conversationFromHookSessionMemory(context.previousSessionMemory, reflectionMessageCount); - if (conversation) { + let conversation = resumedFromBeforeReset + ? options?.beforeResetConversation ?? null + : conversationFromHookSessionMemory(context.previousSessionMemory, reflectionMessageCount); + if (resumedFromBeforeReset) { + api.logger.info(`memory-reflection: command:${action} using the before_reset transcript for session ${currentSessionId}; messages=${conversation ? "present" : "empty"}`); + } + else if (conversation) { api.logger.info(`memory-reflection: command:${action} using the hook-provided transcript for session ${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}`); } else { @@ -4766,6 +4793,11 @@ const memoryLanceDBProPlugin = { currentSessionFile, sourceAgentId, }); + if (isBoundaryAction) { + rememberPendingBeforeResetReflection(sessionKey, { event, sessionId: currentSessionId, action }); + api.logger.info(`memory-reflection: command:${action} no transcript in the hook context or on disk for session ${currentSessionId}; waiting for the typed before_reset messages`); + return; + } api.logger.warn(`memory-reflection: command:${action} missing session file after recovery for session ${currentSessionId}; dirs=${searchDirs.join(" | ") || "(none)"}`); await rememberEmptyReflectionEvent("missing-session-file"); return; @@ -5144,6 +5176,23 @@ const memoryLanceDBProPlugin = { pruneReflectionSessionState(); } }; + const runMemoryReflection = async (event) => runMemoryReflectionWith(event); + const runMemoryReflectionFromBeforeReset = async (event, ctx) => { + const reason = getCommandActionName(event?.reason); + if (reason !== "new" && reason !== "reset") + return; + const sessionKey = typeof ctx?.sessionKey === "string" ? ctx.sessionKey : ""; + if (!sessionKey) + return; + const pending = takePendingBeforeResetReflection(sessionKey); + if (!pending) + return; + const conversation = summarizeRecentConversationMessages(Array.isArray(event?.messages) ? event.messages : [], reflectionMessageCount); + // The command hook that parked this entry ran no reflection, so its serial-guard + // stamp must not count against the continuation. + getSerialGuardMap().delete(sessionKey); + await runMemoryReflectionWith(pending.event, { beforeResetConversation: conversation }); + }; api.registerHook("command:new", runMemoryReflection, { name: "memory-lancedb-pro.memory-reflection.command-new", description: "Generate reflection log before /new", @@ -5152,7 +5201,8 @@ const memoryLanceDBProPlugin = { name: "memory-lancedb-pro.memory-reflection.command-reset", description: "Generate reflection log before /reset", }); - (isCliMode() ? api.logger.debug : api.logger.info)("memory-reflection: integrated hooks registered (command:new, command:reset, after_tool_call, before_prompt_build, session_end)"); + api.on("before_reset", runMemoryReflectionFromBeforeReset); + (isCliMode() ? api.logger.debug : api.logger.info)("memory-reflection: integrated hooks registered (command:new, command:reset, before_reset, after_tool_call, before_prompt_build, session_end)"); } if (config.sessionStrategy === "systemSessionMemory") { const sessionMessageCount = config.sessionMemory?.messageCount ?? 15; diff --git a/index.ts b/index.ts index 558b2ec34..59f8090eb 100644 --- a/index.ts +++ b/index.ts @@ -5842,10 +5842,32 @@ const memoryLanceDBProPlugin = { return g[REFLECTION_SERIAL_GUARD] as Map; }; // SERIAL_GUARD_COOLDOWN_MS moved to DEFAULT_SERIAL_GUARD_COOLDOWN_MS + // A command:new / command:reset hook that finds neither a hook transcript nor a + // session file parks here; the typed before_reset hook, which core fires right + // after the command hooks on every command path, carries the departing messages + // and finishes the reflection from this entry. + const REFLECTION_PENDING_BEFORE_RESET_TTL_MS = 60_000; + type PendingBeforeResetReflection = { event: any; sessionId: string; action: string; at: number }; + const pendingBeforeResetReflections = new Map(); + const rememberPendingBeforeResetReflection = (key: string, entry: Omit) => { + const now = Date.now(); + for (const [pendingKey, pending] of pendingBeforeResetReflections) { + if (now - pending.at > REFLECTION_PENDING_BEFORE_RESET_TTL_MS) pendingBeforeResetReflections.delete(pendingKey); + } + pendingBeforeResetReflections.set(key, { ...entry, at: now }); + }; + const takePendingBeforeResetReflection = (key: string): PendingBeforeResetReflection | undefined => { + const pending = pendingBeforeResetReflections.get(key); + if (!pending) return undefined; + pendingBeforeResetReflections.delete(key); + return Date.now() - pending.at > REFLECTION_PENDING_BEFORE_RESET_TTL_MS ? undefined : pending; + }; - const runMemoryReflection = async (event: any) => { + type ReflectionRunOptions = { beforeResetConversation?: string | null }; + const runMemoryReflectionWith = async (event: any, options?: ReflectionRunOptions) => { const sessionKey = typeof event.sessionKey === "string" ? event.sessionKey : ""; const action = String(event?.action || "unknown"); + const resumedFromBeforeReset = options !== undefined && "beforeResetConversation" in options; // Validate sessionKey BEFORE dedup — invalid/empty keys must NOT pollute the dedup set if (!sessionKey) { @@ -5853,7 +5875,7 @@ const memoryLanceDBProPlugin = { return; } - if (_dedupHookEvent("reflection", event)) return; + if (!resumedFromBeforeReset && _dedupHookEvent("reflection", event)) return; const context = (event.context || {}) as Record; const cfg = context.cfg; const sessionEntry = (context.previousSessionEntry || context.sessionEntry || {}) as Record; @@ -5992,8 +6014,14 @@ const memoryLanceDBProPlugin = { // Hosts with SQLite session storage hand the departing transcript to the // hook itself; the session-file lookup below is the legacy path. - let conversation = conversationFromHookSessionMemory(context.previousSessionMemory, reflectionMessageCount); - if (conversation) { + let conversation = resumedFromBeforeReset + ? options?.beforeResetConversation ?? null + : conversationFromHookSessionMemory(context.previousSessionMemory, reflectionMessageCount); + if (resumedFromBeforeReset) { + api.logger.info( + `memory-reflection: command:${action} using the before_reset transcript for session ${currentSessionId}; messages=${conversation ? "present" : "empty"}` + ); + } else if (conversation) { api.logger.info( `memory-reflection: command:${action} using the hook-provided transcript for session ${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}` ); @@ -6029,6 +6057,13 @@ const memoryLanceDBProPlugin = { currentSessionFile, sourceAgentId, }); + if (isBoundaryAction) { + rememberPendingBeforeResetReflection(sessionKey, { event, sessionId: currentSessionId, action }); + api.logger.info( + `memory-reflection: command:${action} no transcript in the hook context or on disk for session ${currentSessionId}; waiting for the typed before_reset messages` + ); + return; + } api.logger.warn( `memory-reflection: command:${action} missing session file after recovery for session ${currentSessionId}; dirs=${searchDirs.join(" | ") || "(none)"}` ); @@ -6461,6 +6496,23 @@ const memoryLanceDBProPlugin = { pruneReflectionSessionState(); } }; + const runMemoryReflection = async (event: any) => runMemoryReflectionWith(event); + const runMemoryReflectionFromBeforeReset = async (event: any, ctx: any) => { + const reason = getCommandActionName(event?.reason); + if (reason !== "new" && reason !== "reset") return; + const sessionKey = typeof ctx?.sessionKey === "string" ? ctx.sessionKey : ""; + if (!sessionKey) return; + const pending = takePendingBeforeResetReflection(sessionKey); + if (!pending) return; + const conversation = summarizeRecentConversationMessages( + Array.isArray(event?.messages) ? event.messages : [], + reflectionMessageCount, + ); + // The command hook that parked this entry ran no reflection, so its serial-guard + // stamp must not count against the continuation. + getSerialGuardMap().delete(sessionKey); + await runMemoryReflectionWith(pending.event, { beforeResetConversation: conversation }); + }; api.registerHook("command:new", runMemoryReflection, { name: "memory-lancedb-pro.memory-reflection.command-new", @@ -6470,8 +6522,9 @@ const memoryLanceDBProPlugin = { name: "memory-lancedb-pro.memory-reflection.command-reset", description: "Generate reflection log before /reset", }); + api.on("before_reset", runMemoryReflectionFromBeforeReset); (isCliMode() ? api.logger.debug : api.logger.info)( - "memory-reflection: integrated hooks registered (command:new, command:reset, after_tool_call, before_prompt_build, session_end)" + "memory-reflection: integrated hooks registered (command:new, command:reset, before_reset, after_tool_call, before_prompt_build, session_end)" ); } diff --git a/package.json b/package.json index 6c305b66d..71d854ceb 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "skills/**/*.md" ], "scripts": { - "test": "node test/embedder-error-hints.test.mjs && node --test test/embedder-max-input-chars.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/extraction-prompt-structural-noise.test.mjs && node test/i18n-memory-triggers.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/reflection-unattributed-session-read.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node --test test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node --test test/smart-extractor-noise-gating.test.mjs && node test/memory-capability-runtime.test.mjs && node --test test/startup-health-diagnostics.test.mjs && node test/corpus-indexer.test.mjs && node --test test/regex-fallback-bulk-store.test.mjs && node test/plugin-manifest-regression.mjs && node --test test/dreaming-engine.test.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-update-metadata-refresh.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs && node --test test/command-reflection-guard.test.mjs && node --test test/tier1-counters.test.mjs && node --test test/startup-check-timeout.test.mjs && node --test test/memory-subsession-prompt-hooks.test.mjs && node --test test/read-consistency-interval.test.mjs && node --test test/reflection-distiller-hook-skip.test.mjs && node --test test/register-scope-dedup.test.mjs && node --test test/raw-run-distiller-hooks.test.mjs && node --test test/autocapture-watermark-reset.test.mjs && node --test test/autocapture-internal-session-guard.test.mjs && node --test test/memory-categories-storage-map.test.mjs && node --test test/delete-invalidate-reflection-caches.test.mjs && node --test test/reflection-mapped-rows-admission.test.mjs && node --test test/smart-metadata-source-classification.test.mjs && node --test test/reflection-embed-transient-retry.test.mjs && node --test test/scope-owner-leak-hardening.test.mjs && node --test test/isOwnedByAgent.test.mjs && node --test test/typed-array-vector-fetch.test.mjs && node --test test/extraction-grounding-register.test.mjs && node test/grounding-rejudge.test.mjs && node --test test/reverse-map-legacy-category.test.mjs && node --test test/reflection-mapped-category-stamping.test.mjs && node --test test/memory-upgrader-category-normalization.test.mjs && node --test test/autocapture-fallback-gating.test.mjs && node --test test/prompt-architecture.test.mjs && node test/extraction-category-rubric.test.mjs && node --test test/admission-control-batch-utility.test.mjs && node --test test/smart-extractor-batch-admission.test.mjs && node --test test/admission-control-prompt-shape.test.mjs && node --test test/smart-extractor-merge-accounting.test.mjs && node --test test/admission-utility-veto.test.mjs && node --test test/cli-subcommand-attachment.test.mjs && node --test test/admission-lane-model-affinity.test.mjs && node --test test/admission-model-resolution.test.mjs && node --test test/admission-controller-standalone.test.mjs && node --test test/smart-extractor-admission-controller-injection.test.mjs && node --test test/admission-without-smart-extraction.test.mjs && node --test test/llm-thinklevel.test.mjs && node --test test/memory-id-prefix-resolution.test.mjs && node --test test/manual-store-supersede.test.mjs && node --test test/extraction-transcript-speaker-tags.test.mjs && node --test test/session-compressor.test.mjs && node --test test/reflection-derived-cache-invalidation.test.mjs && node --test test/reflection-tagged-input.test.mjs && node --test test/reflection-mapped-uniform-pipeline.test.mjs && node --test test/llm-host-transport.test.mjs && node --test test/llm-host-transport-composition.test.mjs && node --test test/admission-control-host-transport.test.mjs && node --test test/llm-transport-credential-hygiene.test.mjs && node --test test/memory-consolidate.test.mjs && node --test test/memory-consolidate-cost-gate.test.mjs && node --test test/memory-consolidate-two-phase-apply.test.mjs && node --test test/memory-consolidate-admission-independence.test.mjs && node --test test/memory-consolidate-polish.test.mjs && node --test test/consolidate-cli-settled-persistence.test.mjs && node --test test/consolidate-cli-apply-exit-status.test.mjs && node --test test/invalidated-rows-visibility.test.mjs && node --test test/store-excludeinactive-default.test.mjs && node --test test/manual-echo-guard.test.mjs && node --test test/reflection-hook-session-memory.test.mjs && node --test test/reflection-runner-embedded-agent.test.mjs", + "test": "node test/embedder-error-hints.test.mjs && node --test test/embedder-max-input-chars.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/extraction-prompt-structural-noise.test.mjs && node test/i18n-memory-triggers.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/reflection-unattributed-session-read.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node --test test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node --test test/smart-extractor-noise-gating.test.mjs && node test/memory-capability-runtime.test.mjs && node --test test/startup-health-diagnostics.test.mjs && node test/corpus-indexer.test.mjs && node --test test/regex-fallback-bulk-store.test.mjs && node test/plugin-manifest-regression.mjs && node --test test/dreaming-engine.test.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-update-metadata-refresh.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs && node --test test/command-reflection-guard.test.mjs && node --test test/tier1-counters.test.mjs && node --test test/startup-check-timeout.test.mjs && node --test test/memory-subsession-prompt-hooks.test.mjs && node --test test/read-consistency-interval.test.mjs && node --test test/reflection-distiller-hook-skip.test.mjs && node --test test/register-scope-dedup.test.mjs && node --test test/raw-run-distiller-hooks.test.mjs && node --test test/autocapture-watermark-reset.test.mjs && node --test test/autocapture-internal-session-guard.test.mjs && node --test test/memory-categories-storage-map.test.mjs && node --test test/delete-invalidate-reflection-caches.test.mjs && node --test test/reflection-mapped-rows-admission.test.mjs && node --test test/smart-metadata-source-classification.test.mjs && node --test test/reflection-embed-transient-retry.test.mjs && node --test test/scope-owner-leak-hardening.test.mjs && node --test test/isOwnedByAgent.test.mjs && node --test test/typed-array-vector-fetch.test.mjs && node --test test/extraction-grounding-register.test.mjs && node test/grounding-rejudge.test.mjs && node --test test/reverse-map-legacy-category.test.mjs && node --test test/reflection-mapped-category-stamping.test.mjs && node --test test/memory-upgrader-category-normalization.test.mjs && node --test test/autocapture-fallback-gating.test.mjs && node --test test/prompt-architecture.test.mjs && node test/extraction-category-rubric.test.mjs && node --test test/admission-control-batch-utility.test.mjs && node --test test/smart-extractor-batch-admission.test.mjs && node --test test/admission-control-prompt-shape.test.mjs && node --test test/smart-extractor-merge-accounting.test.mjs && node --test test/admission-utility-veto.test.mjs && node --test test/cli-subcommand-attachment.test.mjs && node --test test/admission-lane-model-affinity.test.mjs && node --test test/admission-model-resolution.test.mjs && node --test test/admission-controller-standalone.test.mjs && node --test test/smart-extractor-admission-controller-injection.test.mjs && node --test test/admission-without-smart-extraction.test.mjs && node --test test/llm-thinklevel.test.mjs && node --test test/memory-id-prefix-resolution.test.mjs && node --test test/manual-store-supersede.test.mjs && node --test test/extraction-transcript-speaker-tags.test.mjs && node --test test/session-compressor.test.mjs && node --test test/reflection-derived-cache-invalidation.test.mjs && node --test test/reflection-tagged-input.test.mjs && node --test test/reflection-mapped-uniform-pipeline.test.mjs && node --test test/llm-host-transport.test.mjs && node --test test/llm-host-transport-composition.test.mjs && node --test test/admission-control-host-transport.test.mjs && node --test test/llm-transport-credential-hygiene.test.mjs && node --test test/memory-consolidate.test.mjs && node --test test/memory-consolidate-cost-gate.test.mjs && node --test test/memory-consolidate-two-phase-apply.test.mjs && node --test test/memory-consolidate-admission-independence.test.mjs && node --test test/memory-consolidate-polish.test.mjs && node --test test/consolidate-cli-settled-persistence.test.mjs && node --test test/consolidate-cli-apply-exit-status.test.mjs && node --test test/invalidated-rows-visibility.test.mjs && node --test test/store-excludeinactive-default.test.mjs && node --test test/manual-echo-guard.test.mjs && node --test test/reflection-hook-session-memory.test.mjs && node --test test/reflection-runner-embedded-agent.test.mjs && node --test test/reflection-before-reset-transcript.test.mjs", "test:cli-smoke": "node scripts/run-ci-tests.mjs --group cli-smoke", "test:core-regression": "node scripts/run-ci-tests.mjs --group core-regression", "test:storage-and-schema": "node scripts/run-ci-tests.mjs --group storage-and-schema", diff --git a/scripts/ci-test-manifest.mjs b/scripts/ci-test-manifest.mjs index 0daed66e2..83e91518a 100644 --- a/scripts/ci-test-manifest.mjs +++ b/scripts/ci-test-manifest.mjs @@ -100,6 +100,7 @@ export const CI_TEST_MANIFEST = [ { group: "core-regression", runner: "node", file: "test/agentid-validation.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/command-reflection-guard.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/reflection-hook-session-memory.test.mjs", args: ["--test"] }, + { group: "core-regression", runner: "node", file: "test/reflection-before-reset-transcript.test.mjs", args: ["--test"] }, { group: "core-regression", runner: "node", file: "test/reflection-runner-embedded-agent.test.mjs", args: ["--test"] }, // Tier 1 memory counter fix { group: "core-regression", runner: "node", file: "test/tier1-counters.test.mjs", args: ["--test"] }, diff --git a/test/reflection-before-reset-transcript.test.mjs b/test/reflection-before-reset-transcript.test.mjs new file mode 100644 index 000000000..cb352213d --- /dev/null +++ b/test/reflection-before-reset-transcript.test.mjs @@ -0,0 +1,203 @@ +/** + * reflection-before-reset-transcript.test.mjs + * + * On hosts with SQLite session storage the command:new hook can arrive with no + * transcript at all (the Gateway command path emits it before it captures one), + * while the typed before_reset hook, fired right after it on every path, + * carries the departing messages. The reflection parks on the command hook and + * finishes from the before_reset messages; it must never run twice for one + * boundary and must ignore before_reset events that belong to no parked hook. + * Fixtures are synthetic. + * + * Run: node --test test/reflection-before-reset-transcript.test.mjs + */ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "os"; +import path from "path"; +import { fileURLToPath } from "node:url"; +import jitiFactory from "jiti"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const pluginSdkStubPath = path.resolve(testDir, "helpers", "openclaw-plugin-sdk-stub.mjs"); +const jiti = jitiFactory(import.meta.url, { + interopDefault: true, + alias: { "openclaw/plugin-sdk": pluginSdkStubPath }, +}); + +const pluginModule = jiti("../index.ts"); +const memoryLanceDBProPlugin = pluginModule.default || pluginModule; +const resetRegistration = pluginModule.resetRegistration ?? (() => {}); + +function createPluginApiHarness({ pluginConfig, resolveRoot }) { + const eventHandlers = new Map(); + const logs = []; + const api = { + pluginConfig, + resolvePath(target) { + if (typeof target !== "string") return target; + return path.isAbsolute(target) ? target : path.join(resolveRoot, target); + }, + logger: { + info(message) { logs.push(String(message)); }, + warn(message) { logs.push(String(message)); }, + debug(message) { logs.push(String(message)); }, + error(message) { logs.push(String(message)); }, + }, + registerTool() {}, + registerCli() {}, + registerService() {}, + on(eventName, handler, meta) { + const list = eventHandlers.get(eventName) || []; + list.push({ handler, meta }); + eventHandlers.set(eventName, list); + }, + registerHook(eventName, handler, opts) { + const list = eventHandlers.get(eventName) || []; + list.push({ handler, meta: opts }); + eventHandlers.set(eventName, list); + }, + }; + return { api, eventHandlers, logs }; +} + +function makePluginConfig(workDir) { + return { + dbPath: path.join(workDir, "db"), + embedding: { apiKey: "test-api-key", dimensions: 4 }, + sessionStrategy: "memoryReflection", + smartExtraction: false, + autoCapture: false, + autoRecall: false, + selfImprovement: { enabled: false, beforeResetNote: false, ensureLearningFiles: false }, + memoryReflection: { excludeAgents: [] }, + }; +} + +const DEPARTING_MESSAGES = [ + { role: "user", content: "Please remember that the rehearsal moved to Thursday." }, + { role: "assistant", content: "Noted, the rehearsal is on Thursday now." }, + { role: "user", content: "And the venue stays the same, the old town hall." }, +]; + +const HOOK_TRANSCRIPT = [ + 'user: "Please remember that the rehearsal moved to Thursday."', + 'assistant: "Noted, the rehearsal is on Thursday now."', +].join("\n"); + +describe("reflection finishes from the typed before_reset messages", () => { + let workDir; + let originalCliBin; + + beforeEach(() => { + workDir = mkdtempSync(path.join(tmpdir(), "reflect-before-reset-")); + resetRegistration(); + originalCliBin = process.env.OPENCLAW_CLI_BIN; + process.env.OPENCLAW_CLI_BIN = "/usr/bin/false"; + }); + + afterEach(() => { + resetRegistration(); + if (originalCliBin === undefined) delete process.env.OPENCLAW_CLI_BIN; + else process.env.OPENCLAW_CLI_BIN = originalCliBin; + rmSync(workDir, { recursive: true, force: true }); + }); + + function registered() { + const pluginConfig = makePluginConfig(workDir); + const harness = createPluginApiHarness({ resolveRoot: workDir, pluginConfig }); + memoryLanceDBProPlugin.register(harness.api); + const commandHooks = harness.eventHandlers.get("command:new") || []; + const beforeResetHooks = harness.eventHandlers.get("before_reset") || []; + assert.equal(commandHooks.length, 1, "one command:new reflection hook"); + assert.equal(beforeResetHooks.length, 1, "exactly one before_reset listener under the memoryReflection strategy"); + return { harness, pluginConfig, commandHook: commandHooks[0].handler, beforeResetHook: beforeResetHooks[0].handler }; + } + + async function fireCommandNew({ commandHook, pluginConfig }, sessionId, extraContext = {}) { + const sessionKey = `agent:main:session:${sessionId}`; + await commandHook( + { + sessionKey, + timestamp: 1000, + action: "command:new", + context: { cfg: pluginConfig, workspaceDir: workDir, previousSessionEntry: { sessionId }, ...extraContext }, + }, + { sessionKey, agentId: "main" }, + ); + return sessionKey; + } + + async function fireBeforeReset({ beforeResetHook }, sessionKey, sessionId, { reason = "new", messages = DEPARTING_MESSAGES } = {}) { + await beforeResetHook( + { sessionFile: `sqlite:${sessionId}`, messages, reason }, + { agentId: "main", sessionKey, sessionId, workspaceDir: workDir }, + ); + } + + it("parks on a command hook without any transcript and reflects from the before_reset messages", async () => { + const setup = registered(); + const sessionKey = await fireCommandNew(setup, "parked"); + const { logs } = setup.harness; + assert.ok( + logs.some((m) => m.includes("no transcript in the hook context or on disk for session parked; waiting for the typed before_reset messages")), + `got ${JSON.stringify(logs)}`, + ); + assert.ok(!logs.some((m) => m.includes("missing session file after recovery")), "no legacy missing-file warning"); + assert.ok(!logs.some((m) => m.includes("empty/unusable guard recorded")), "parking is not an empty session"); + assert.ok(!logs.some((m) => m.includes("reflection generation start")), "nothing ran yet"); + + await fireBeforeReset(setup, sessionKey, "parked"); + assert.ok( + logs.some((m) => m.includes("using the before_reset transcript for session parked; messages=present")), + `got ${JSON.stringify(logs)}`, + ); + assert.equal( + logs.filter((m) => m.includes("reflection generation start for session parked")).length, + 1, + `the parked boundary reflects exactly once: ${JSON.stringify(logs)}`, + ); + }); + + it("ignores a before_reset event that belongs to no parked command hook", async () => { + const setup = registered(); + await fireBeforeReset(setup, "agent:main:session:orphan", "orphan"); + const { logs } = setup.harness; + assert.ok(!logs.some((m) => m.includes("using the before_reset transcript")), JSON.stringify(logs)); + assert.ok(!logs.some((m) => m.includes("reflection generation start")), JSON.stringify(logs)); + }); + + it("does not reflect twice when the command hook already carried the transcript", async () => { + const setup = registered(); + const sessionKey = await fireCommandNew(setup, "carried", { + previousSessionMemory: { status: "available", content: HOOK_TRANSCRIPT, originClass: "agent" }, + }); + const { logs } = setup.harness; + assert.ok(logs.some((m) => m.includes("using the hook-provided transcript for session carried")), JSON.stringify(logs)); + await fireBeforeReset(setup, sessionKey, "carried"); + assert.equal(logs.filter((m) => m.includes("reflection generation start for session carried")).length, 1, JSON.stringify(logs)); + assert.ok(!logs.some((m) => m.includes("using the before_reset transcript")), "the continuation must not run"); + }); + + it("records the empty guard when the before_reset messages are empty", async () => { + const setup = registered(); + const sessionKey = await fireCommandNew(setup, "empty"); + await fireBeforeReset(setup, sessionKey, "empty", { messages: [] }); + const { logs } = setup.harness; + assert.ok(logs.some((m) => m.includes("using the before_reset transcript for session empty; messages=empty")), JSON.stringify(logs)); + assert.ok(logs.some((m) => m.includes("conversation empty/unusable for session empty")), JSON.stringify(logs)); + assert.ok(logs.some((m) => m.includes("empty/unusable guard recorded")), JSON.stringify(logs)); + assert.ok(!logs.some((m) => m.includes("reflection generation start")), "nothing to reflect on"); + }); + + it("leaves a parked hook alone for before_reset reasons that are not a session boundary", async () => { + const setup = registered(); + const sessionKey = await fireCommandNew(setup, "idle"); + await fireBeforeReset(setup, sessionKey, "idle", { reason: "idle" }); + const { logs } = setup.harness; + assert.ok(!logs.some((m) => m.includes("using the before_reset transcript")), JSON.stringify(logs)); + await fireBeforeReset(setup, sessionKey, "idle"); + assert.ok(logs.some((m) => m.includes("using the before_reset transcript for session idle; messages=present")), "the boundary reason finishes it"); + }); +}); diff --git a/test/reflection-hook-session-memory.test.mjs b/test/reflection-hook-session-memory.test.mjs index a05f36eb7..2b77cf430 100644 --- a/test/reflection-hook-session-memory.test.mjs +++ b/test/reflection-hook-session-memory.test.mjs @@ -155,7 +155,7 @@ describe("runMemoryReflection reads the transcript the hook already carries", () }, "empty-hook", ); - assert.ok(logs.some((m) => m.includes("missing session file after recovery for session empty-hook")), `got ${JSON.stringify(logs)}`); + assert.ok(logs.some((m) => m.includes("no transcript in the hook context or on disk for session empty-hook")), `got ${JSON.stringify(logs)}`); assert.ok(!logs.some((m) => m.includes("reflection generation start")), "nothing to reflect on"); const unavailable = await invoke( @@ -165,7 +165,7 @@ describe("runMemoryReflection reads the transcript the hook already carries", () }, "unavailable-hook", ); - assert.ok(unavailable.some((m) => m.includes("missing session file after recovery for session unavailable-hook")), `got ${JSON.stringify(unavailable)}`); + assert.ok(unavailable.some((m) => m.includes("no transcript in the hook context or on disk for session unavailable-hook")), `got ${JSON.stringify(unavailable)}`); }); }); From 28cfc630cf80d18550bbda84398c653d6e6157a2 Mon Sep 17 00:00:00 2001 From: Gorkem Date: Sat, 12 Sep 2026 23:03:10 +0300 Subject: [PATCH 5/6] fix(reflection): run the before_reset continuation outside the command's root-work context Core refuses embedded sub-runs enqueued from a released root-work context, which is where the fire-and-forget before_reset hook runs, so the continuation fell back to the CLI runner on every /new. Capture an async context snapshot at registration and run the continuation in it; the global restart and suspension fences still apply. --- dist/index.js | 10 ++++- index.ts | 13 +++++- ...eflection-before-reset-transcript.test.mjs | 41 +++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/dist/index.js b/dist/index.js index 6d7d591e9..2da1eb0d3 100644 --- a/dist/index.js +++ b/dist/index.js @@ -10,6 +10,7 @@ import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; import { createRequire } from "node:module"; import { spawn } from "node:child_process"; +import { AsyncLocalStorage } from "node:async_hooks"; // Detect CLI mode: when running as a CLI subcommand (e.g. `openclaw memory-pro stats`), // OpenClaw sets OPENCLAW_CLI=1 in the process environment. Registration and // lifecycle logs are noisy in CLI context (printed to stderr before command output), @@ -4624,6 +4625,13 @@ const memoryLanceDBProPlugin = { pendingBeforeResetReflections.delete(key); return Date.now() - pending.at > REFLECTION_PENDING_BEFORE_RESET_TTL_MS ? undefined : pending; }; + // Captured at registration, outside any command's root-work context. The + // before_reset continuation would otherwise inherit the released /new root, + // and core refuses embedded sub-runs from a released root (subordinate work + // admission), which would push every /new reflection to the CLI runner. + const runOutsideCommandRootWork = typeof AsyncLocalStorage.snapshot === "function" + ? AsyncLocalStorage.snapshot() + : (fn) => fn(); const runMemoryReflectionWith = async (event, options) => { const sessionKey = typeof event.sessionKey === "string" ? event.sessionKey : ""; const action = String(event?.action || "unknown"); @@ -5191,7 +5199,7 @@ const memoryLanceDBProPlugin = { // The command hook that parked this entry ran no reflection, so its serial-guard // stamp must not count against the continuation. getSerialGuardMap().delete(sessionKey); - await runMemoryReflectionWith(pending.event, { beforeResetConversation: conversation }); + await runOutsideCommandRootWork(() => runMemoryReflectionWith(pending.event, { beforeResetConversation: conversation })); }; api.registerHook("command:new", runMemoryReflection, { name: "memory-lancedb-pro.memory-reflection.command-new", diff --git a/index.ts b/index.ts index 59f8090eb..ce08851ae 100644 --- a/index.ts +++ b/index.ts @@ -12,6 +12,7 @@ import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; import { createRequire } from "node:module"; import { spawn } from "node:child_process"; +import { AsyncLocalStorage } from "node:async_hooks"; // Detect CLI mode: when running as a CLI subcommand (e.g. `openclaw memory-pro stats`), // OpenClaw sets OPENCLAW_CLI=1 in the process environment. Registration and @@ -5862,6 +5863,14 @@ const memoryLanceDBProPlugin = { pendingBeforeResetReflections.delete(key); return Date.now() - pending.at > REFLECTION_PENDING_BEFORE_RESET_TTL_MS ? undefined : pending; }; + // Captured at registration, outside any command's root-work context. The + // before_reset continuation would otherwise inherit the released /new root, + // and core refuses embedded sub-runs from a released root (subordinate work + // admission), which would push every /new reflection to the CLI runner. + const runOutsideCommandRootWork: (fn: () => R) => R = + typeof (AsyncLocalStorage as { snapshot?: unknown }).snapshot === "function" + ? (AsyncLocalStorage as unknown as { snapshot: () => (fn: () => R) => R }).snapshot() + : (fn) => fn(); type ReflectionRunOptions = { beforeResetConversation?: string | null }; const runMemoryReflectionWith = async (event: any, options?: ReflectionRunOptions) => { @@ -6511,7 +6520,9 @@ const memoryLanceDBProPlugin = { // The command hook that parked this entry ran no reflection, so its serial-guard // stamp must not count against the continuation. getSerialGuardMap().delete(sessionKey); - await runMemoryReflectionWith(pending.event, { beforeResetConversation: conversation }); + await runOutsideCommandRootWork(() => + runMemoryReflectionWith(pending.event, { beforeResetConversation: conversation }), + ); }; api.registerHook("command:new", runMemoryReflection, { diff --git a/test/reflection-before-reset-transcript.test.mjs b/test/reflection-before-reset-transcript.test.mjs index cb352213d..a8b614f43 100644 --- a/test/reflection-before-reset-transcript.test.mjs +++ b/test/reflection-before-reset-transcript.test.mjs @@ -18,6 +18,7 @@ import { tmpdir } from "os"; import path from "path"; import { fileURLToPath } from "node:url"; import jitiFactory from "jiti"; +import { AsyncLocalStorage } from "node:async_hooks"; const testDir = path.dirname(fileURLToPath(import.meta.url)); const pluginSdkStubPath = path.resolve(testDir, "helpers", "openclaw-plugin-sdk-stub.mjs"); @@ -200,4 +201,44 @@ describe("reflection finishes from the typed before_reset messages", () => { await fireBeforeReset(setup, sessionKey, "idle"); assert.ok(logs.some((m) => m.includes("using the before_reset transcript for session idle; messages=present")), "the boundary reason finishes it"); }); + + it("runs the continuation outside the command's async context so the embedded runner is admitted", async () => { + // Core refuses embedded sub-runs enqueued from a released root-work context + // (the /new command's), which is exactly where the fire-and-forget + // before_reset hook runs. The continuation must not inherit that context. + const callerContext = new AsyncLocalStorage(); + // The embedded-runner loader caches its result per module instance, and the + // earlier cases ran without a runtime; load a fresh plugin module here. + const freshModule = jitiFactory(import.meta.url, { + interopDefault: true, + moduleCache: false, + alias: { "openclaw/plugin-sdk": pluginSdkStubPath }, + })("../index.ts"); + const freshPlugin = freshModule.default || freshModule; + const pluginConfig = makePluginConfig(workDir); + const harness = createPluginApiHarness({ resolveRoot: workDir, pluginConfig }); + let storeSeenByRunner = "not invoked"; + harness.api.runtime = { + agent: { + runEmbeddedAgent: async () => { + storeSeenByRunner = callerContext.getStore(); + throw new Error("synthetic runner stop"); + }, + }, + }; + (freshModule.resetRegistration ?? (() => {}))(); + freshPlugin.register(harness.api); + const setup = { + harness, + pluginConfig, + commandHook: harness.eventHandlers.get("command:new")[0].handler, + beforeResetHook: harness.eventHandlers.get("before_reset")[0].handler, + }; + await callerContext.run({ released: true }, async () => { + const sessionKey = await fireCommandNew(setup, "escaped"); + await fireBeforeReset(setup, sessionKey, "escaped"); + }); + assert.equal(storeSeenByRunner, undefined, "the embedded runner must not see the caller's released root context"); + assert.ok(harness.logs.some((m) => m.includes("reflection generation start for session escaped")), JSON.stringify(harness.logs)); + }); }); From 6f2854aa448528588a1059bde8da2b1bf5e12c06 Mon Sep 17 00:00:00 2001 From: Gorkem Date: Sun, 13 Sep 2026 09:06:40 +0300 Subject: [PATCH 6/6] fix(reflection): tool-free completion fallback, atomic runner cache, park empty transcripts for before_reset The CLI fallback ran the departing transcript through an agent turn with the host's default tool surface and no --agent flag; replace it with a plain completion on the plugin's own LLM lane (completeText on every transport), so no tool can be reached and no agent identity has to be resolved. Cache the embedded runner together with its kind so a later host surface cannot relabel it. A recovered transcript file that yields no usable conversation parks the boundary for the typed before_reset messages instead of recording the empty guard. --- dist/index.js | 316 ++++----------- dist/src/llm-client.js | 153 ++++++- index.ts | 382 ++++-------------- src/llm-client.ts | 166 ++++++-- test/command-reflection-guard.test.mjs | 13 +- test/memory-reflection.test.mjs | 4 +- ...eflection-before-reset-transcript.test.mjs | 18 +- .../reflection-runner-embedded-agent.test.mjs | 195 ++++----- test/windows-reflection-fallback.test.mjs | 16 - 9 files changed, 565 insertions(+), 698 deletions(-) diff --git a/dist/index.js b/dist/index.js index 2da1eb0d3..569bd0b90 100644 --- a/dist/index.js +++ b/dist/index.js @@ -9,7 +9,6 @@ import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; import { createRequire } from "node:module"; -import { spawn } from "node:child_process"; import { AsyncLocalStorage } from "node:async_hooks"; // Detect CLI mode: when running as a CLI subcommand (e.g. `openclaw memory-pro stats`), // OpenClaw sets OPENCLAW_CLI=1 in the process environment. Registration and @@ -489,27 +488,23 @@ export function resolveEmbeddedRunnerExportName(candidate) { const record = candidate; return EMBEDDED_RUNNER_EXPORT_NAMES.find((name) => typeof record[name] === "function"); } -let embeddedRunnerExportName; +let resolvedEmbeddedRunnerKind; export function getEmbeddedRunnerExportName() { - return embeddedRunnerExportName; -} -// Legacy hosts read the distiller transcript from a file path; current hosts -// treat a non-key sessionFile as a foreign transcript and refuse the run. -function embeddedRunnerTakesSessionFile() { - return embeddedRunnerExportName !== "runEmbeddedAgent"; + return resolvedEmbeddedRunnerKind; } +// The runner and its kind are cached together: a host surface seen later must +// not relabel an already cached runner (a legacy runner needs the transcript +// file, a current one refuses it). // eslint-disable-next-line import/export export async function loadEmbeddedPiRunner(api) { // Layer 1: 嘗試新 SDK API (with circuit breaker) - if (!isLayer1CircuitOpen()) { + if (!embeddedPiRunnerPromise && !isLayer1CircuitOpen()) { const newApi = (api.runtime?.agent); const runnerName = resolveEmbeddedRunnerExportName(newApi); if (newApi && runnerName) { - embeddedRunnerExportName = runnerName; const runner = newApi[runnerName].bind(newApi); - // Bug 2 fix: 將 Layer 1 結果寫入 cache,避免後續並發呼叫時 Layer 2 覆蓋掉 Layer 1 - embeddedPiRunnerPromise ??= Promise.resolve(runner); - return embeddedPiRunnerPromise; + resolvedEmbeddedRunnerKind = runnerName; + embeddedPiRunnerPromise = Promise.resolve({ runner, exportName: runnerName }); } } // Layer 2: Fallback 舊 extensionAPI.js @@ -521,8 +516,8 @@ export async function loadEmbeddedPiRunner(api) { const mod = await import(specifier); const runnerName = resolveEmbeddedRunnerExportName(mod); if (runnerName) { - embeddedRunnerExportName = runnerName; - return mod[runnerName]; + resolvedEmbeddedRunnerKind = runnerName; + return { runner: mod[runnerName], exportName: runnerName }; } importErrors.push(`${specifier}: runEmbeddedAgent export not found`); } @@ -541,30 +536,10 @@ export async function loadEmbeddedPiRunner(api) { } catch (err) { embeddedPiRunnerPromise = null; + resolvedEmbeddedRunnerKind = undefined; throw err; } } -function clipDiagnostic(text, maxLen = 400) { - const oneLine = text.replace(/\s+/g, " ").trim(); - if (oneLine.length <= maxLen) - return oneLine; - return `${oneLine.slice(0, maxLen - 3)}...`; -} -const ANSI_ESCAPE_RE = /\u001b\[[0-9;]*[A-Za-z]/g; -const CLI_STARTUP_NOISE_LINE_RE = /^\s*\[state-migrations\]/; -function stripAnsi(text) { - return text.replace(ANSI_ESCAPE_RE, ""); -} -// CLI failures print the reason last, after startup banners; keep the tail. -export function clipDiagnosticTail(text, maxLen = 400) { - const lines = stripAnsi(text) - .split(/\r?\n/) - .filter((line) => line.trim().length > 0 && !CLI_STARTUP_NOISE_LINE_RE.test(line)); - const oneLine = lines.join(" ").replace(/\s+/g, " ").trim(); - if (oneLine.length <= maxLen) - return oneLine; - return `...${oneLine.slice(oneLine.length - (maxLen - 3))}`; -} function withTimeout(promise, timeoutMs, label) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -579,187 +554,6 @@ function withTimeout(promise, timeoutMs, label) { }); }); } -function tryParseJsonObject(raw) { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed; - } - } - catch { - // ignore - } - return null; -} -function extractJsonObjectFromOutput(stdout) { - const trimmed = stdout.trim(); - if (!trimmed) - throw new Error("empty stdout"); - const direct = tryParseJsonObject(trimmed); - if (direct) - return direct; - const lines = trimmed.split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - if (!lines[i].trim().startsWith("{")) - continue; - const candidate = lines.slice(i).join("\n"); - const parsed = tryParseJsonObject(candidate); - if (parsed) - return parsed; - } - throw new Error(`unable to parse JSON from CLI output: ${clipDiagnostic(trimmed, 280)}`); -} -export function extractReflectionTextFromCliResult(resultObj) { - const result = resultObj.result; - const payloads = Array.isArray(resultObj.payloads) - ? resultObj.payloads - : Array.isArray(result?.payloads) - ? result.payloads - : []; - const firstWithText = payloads.find((p) => p && typeof p === "object" && typeof p.text === "string" && p.text.trim().length); - const text = typeof firstWithText?.text === "string" ? firstWithText.text.trim() : ""; - if (text) - return text; - const finalText = typeof resultObj.final === "string" ? resultObj.final.trim() : ""; - return finalText || null; -} -// Hosts without `agent exec` reject its argument shape before doing any work. -const LEGACY_CLI_SHAPE_REJECTION_RE = /unknown command|does not recognize|unknown option|too many arguments/i; -export function buildReflectionCliArgs(params) { - if (params.mode === "legacy-local") { - return [ - "agent", - "--local", - "--agent", - params.agentId, - "--message", - params.prompt, - "--json", - "--thinking", - params.thinkLevel, - "--timeout", - String(params.agentTimeoutSec), - "--session-id", - params.sessionId, - ]; - } - const args = [ - "agent", - "exec", - "--message-file", - "-", - "--cwd", - params.workspaceDir, - "--json", - "--thinking", - params.thinkLevel, - "--timeout", - String(params.agentTimeoutSec), - ]; - if (params.modelRef) - args.push("--model", params.modelRef); - return args; -} -export function shouldRetryReflectionCliAsLegacyLocal(run) { - if (run.timedOut || run.signal || run.code === 0) - return false; - return LEGACY_CLI_SHAPE_REJECTION_RE.test(stripAnsi(run.stderr)); -} -async function spawnReflectionCli(params) { - return await new Promise((resolve, reject) => { - const spawnCommand = buildReflectionCliSpawnCommand(params.cliBin, params.args); - const child = spawn(spawnCommand.command, spawnCommand.args, { - cwd: params.cwd, - env: { ...process.env, NO_COLOR: "1" }, - stdio: [params.stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - let settled = false; - let timedOut = false; - const timer = setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - setTimeout(() => child.kill("SIGKILL"), 1500).unref(); - }, params.outerTimeoutMs); - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - if (params.stdinText !== undefined && child.stdin) { - child.stdin.on("error", () => { }); - child.stdin.end(params.stdinText); - } - child.once("error", (err) => { - if (settled) - return; - settled = true; - clearTimeout(timer); - reject(new Error(`spawn ${params.cliBin} failed: ${err.message}`)); - }); - child.once("close", (code, signal) => { - if (settled) - return; - settled = true; - clearTimeout(timer); - resolve({ stdout, stderr, code, signal, timedOut }); - }); - }); -} -async function runReflectionViaCli(params) { - const cliBin = process.env.OPENCLAW_CLI_BIN?.trim() || "openclaw"; - const outerTimeoutMs = Math.max(params.timeoutMs + 5000, 15000); - const agentTimeoutSec = Math.max(1, Math.ceil(params.timeoutMs / 1000)); - const sessionId = `memory-reflection-cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const argParams = { - agentId: params.agentId, - prompt: params.prompt, - workspaceDir: params.workspaceDir, - thinkLevel: params.thinkLevel, - agentTimeoutSec, - sessionId, - modelRef: params.modelRef, - }; - let run = await spawnReflectionCli({ - cliBin, - args: buildReflectionCliArgs({ ...argParams, mode: "exec" }), - cwd: params.workspaceDir, - outerTimeoutMs, - stdinText: params.prompt, - }); - if (shouldRetryReflectionCliAsLegacyLocal(run)) { - run = await spawnReflectionCli({ - cliBin, - args: buildReflectionCliArgs({ ...argParams, mode: "legacy-local" }), - cwd: params.workspaceDir, - outerTimeoutMs, - }); - } - if (run.timedOut) - throw new Error(`${cliBin} timed out after ${outerTimeoutMs}ms`); - if (run.signal) - throw new Error(`${cliBin} exited by signal ${run.signal}. stderr=${clipDiagnosticTail(run.stderr)}`); - if (run.code !== 0) - throw new Error(`${cliBin} exited with code ${run.code}. stderr=${clipDiagnosticTail(run.stderr)}`); - const parsed = extractJsonObjectFromOutput(run.stdout); - const text = extractReflectionTextFromCliResult(parsed); - if (!text) - throw new Error(`CLI JSON returned no text payload. stdout=${clipDiagnostic(run.stdout)}`); - return text; -} -export function buildReflectionCliSpawnCommand(cliBin, args, platform = process.platform, comSpec = process.env.ComSpec?.trim()) { - if (platform === "win32") { - return { - command: comSpec || "cmd.exe", - args: ["/c", cliBin, ...args], - }; - } - return { command: cliBin, args }; -} async function loadSelfImprovementReminderContent(workspaceDir) { const baseDir = typeof workspaceDir === "string" && workspaceDir.trim().length ? workspaceDir.trim() : ""; if (!baseDir) @@ -1463,7 +1257,6 @@ async function generateReflectionTextUnbounded(params) { params.logger?.info?.(message); }; const { provider, model } = resolveReflectionModelTarget(params); - const cliModelRef = provider && model ? `${provider}/${model}` : undefined; try { const result = await runWithReflectionTransientRetryOnce({ scope: "reflection", @@ -1471,7 +1264,8 @@ async function generateReflectionTextUnbounded(params) { retryState, onLog: onRetryLog, execute: async () => { - const runEmbeddedPiAgent = await loadEmbeddedPiRunner(params.api); + const embedded = await loadEmbeddedPiRunner(params.api); + const runEmbeddedPiAgent = embedded.runner; const embeddedTimeoutMs = Math.max(params.timeoutMs + 5000, 15000); return await withTimeout(runEmbeddedPiAgent({ sessionId: `reflection-${Date.now()}`, @@ -1479,7 +1273,7 @@ async function generateReflectionTextUnbounded(params) { // The distiller run is throwaway: keep it out of the host session store. sessionPersistence: "detached", agentId: params.agentId, - ...(embeddedRunnerTakesSessionFile() ? { sessionFile: tempSessionFile } : {}), + ...(embedded.exportName !== "runEmbeddedAgent" ? { sessionFile: tempSessionFile } : {}), workspaceDir: params.workspaceDir, config: params.cfg, prompt, @@ -1525,24 +1319,28 @@ async function generateReflectionTextUnbounded(params) { if (reflectionText) { return { text: reflectionText, usedFallback: false, promptHash, error: errors[0], runner: "embedded" }; } - try { - reflectionText = await runWithReflectionTransientRetryOnce({ - scope: "reflection", - runner: "cli", - retryState, - onLog: onRetryLog, - execute: async () => await runReflectionViaCli({ - prompt, - agentId: params.agentId, - workspaceDir: params.workspaceDir, - timeoutMs: params.timeoutMs, - thinkLevel: params.thinkLevel, - modelRef: cliModelRef, - }), - }); + if (params.completeText) { + const completeText = params.completeText; + try { + reflectionText = await runWithReflectionTransientRetryOnce({ + scope: "reflection", + runner: "completion", + retryState, + onLog: onRetryLog, + execute: async () => { + const text = await completeText(reflectionSystemPrompt, reflectionUserPrompt); + if (!text) + throw new Error("completion returned no text"); + return text; + }, + }); + } + catch (err) { + errors.push(`completion: ${err instanceof Error ? err.message : String(err)}`); + } } - catch (err) { - errors.push(`cli: ${err instanceof Error ? err.message : String(err)}`); + else { + errors.push("completion: no tool-free completion client on this host"); } if (reflectionText) { return { @@ -1550,7 +1348,7 @@ async function generateReflectionTextUnbounded(params) { usedFallback: false, promptHash, error: errors.length > 0 ? errors.join(" | ") : undefined, - runner: "cli", + runner: "completion", }; } return { @@ -2217,6 +2015,10 @@ function _initPluginState(api) { const captureAdmissionController = () => admissionController; const captureAdmissionAudit = () => admissionController !== null && config.admissionControl?.auditMetadata !== false; const captureReflectionAdmissionController = () => admissionControllerReflectionLane; + const makeLaneLlmClient = (model, thinkLevel, modelExplicit) => { + const { makeClientForModel, llmModelExplicit } = buildMemoryLlmClient(); + return makeClientForModel(model, thinkLevel, modelExplicit ?? llmModelExplicit); + }; const extractionRateLimiter = createExtractionRateLimiter({ maxExtractionsPerHour: config.extractionThrottle?.maxExtractionsPerHour, }); @@ -2274,6 +2076,7 @@ function _initPluginState(api) { captureAdmissionController, captureAdmissionAudit, captureReflectionAdmissionController, + makeLaneLlmClient, admissionRejectionAuditWriter, }; } @@ -2383,7 +2186,7 @@ const memoryLanceDBProPlugin = { _registeredApisMap.delete(api); // dual-track rollback: Map un-claim throw err; } - const { config, resolvedDbPath, vectorDim, store, embedder, retriever, canonicalCorpusIndexer, dreamingEngine, dreamingScheduler, scopeManager, migrator, smartExtractor, manualEchoLedger, mdMirror, decayEngine, tierManager, extractionRateLimiter, reflectionErrorStateBySession, reflectionDerivedBySession, reflectionDerivedSuppressionBySession, reflectionByAgentCache, reflectionByAgentCacheGeneration, recallHistory, turnCounter, autoCaptureSeenTextCount, autoCapturePendingIngressTexts, autoCaptureCountedPendingCount, autoCaptureRecentTurns, autoCaptureDeferredFlushTurns, autoCaptureSessionIdToKey, autoCaptureInFlightRuns, captureAdmissionController, captureAdmissionAudit, captureReflectionAdmissionController, admissionRejectionAuditWriter, } = singleton; + const { config, resolvedDbPath, vectorDim, store, embedder, retriever, canonicalCorpusIndexer, dreamingEngine, dreamingScheduler, scopeManager, migrator, smartExtractor, manualEchoLedger, mdMirror, decayEngine, tierManager, extractionRateLimiter, reflectionErrorStateBySession, reflectionDerivedBySession, reflectionDerivedSuppressionBySession, reflectionByAgentCache, reflectionByAgentCacheGeneration, recallHistory, turnCounter, autoCaptureSeenTextCount, autoCapturePendingIngressTexts, autoCaptureCountedPendingCount, autoCaptureRecentTurns, autoCaptureDeferredFlushTurns, autoCaptureSessionIdToKey, autoCaptureInFlightRuns, captureAdmissionController, captureAdmissionAudit, captureReflectionAdmissionController, makeLaneLlmClient, admissionRejectionAuditWriter, } = singleton; const learnAutoCaptureSessionAlias = (sessionId, sessionKey) => { if (typeof sessionId !== "string" || !sessionId || typeof sessionKey !== "string" || !sessionKey @@ -4416,6 +4219,27 @@ const memoryLanceDBProPlugin = { const reflectionMaxConcurrentRuns = config.memoryReflection?.maxConcurrentRuns ?? DEFAULT_REFLECTION_MAX_CONCURRENT_RUNS; const reflectionAgentId = asNonEmptyString(config.memoryReflection?.agentId); const reflectionModel = asNonEmptyString(config.memoryReflection?.model); + // Tool-free fallback for the distiller when the embedded runner is + // unavailable: a plain completion on the plugin's own LLM lane, so the + // transcript never reaches an agent turn that could invoke tools. + let reflectionCompletionClient; + const reflectionCompleteText = async (systemPrompt, userPrompt) => { + if (reflectionCompletionClient === undefined) { + const model = reflectionModel ?? asNonEmptyString(config.llm?.model); + try { + reflectionCompletionClient = model + ? makeLaneLlmClient(config.llm?.transport === "host" ? model.trim() : normalizeDirectModelRef(model), reflectionThinkLevel, reflectionModel ? true : undefined) + : null; + } + catch (err) { + api.logger.warn(`memory-reflection: completion fallback unavailable: ${err instanceof Error ? err.message : String(err)}`); + reflectionCompletionClient = null; + } + } + if (!reflectionCompletionClient) + return null; + return reflectionCompletionClient.completeText(userPrompt, "memory-reflection", systemPrompt); + }; const reflectionErrorReminderMaxEntries = parsePositiveInt(config.memoryReflection?.errorReminderMaxEntries) ?? DEFAULT_REFLECTION_ERROR_REMINDER_MAX_ENTRIES; const reflectionDedupeErrorSignals = config.memoryReflection?.dedupeErrorSignals !== false; const reflectionInjectMode = config.memoryReflection?.injectMode ?? "inheritance+derived"; @@ -4714,7 +4538,7 @@ const memoryLanceDBProPlugin = { sessionFile: currentSessionFile, }); const guarded = getReflectionEmptyEventGuardMap().get(emptyEventGuardKey); - if (guarded && Date.now() - guarded.updatedAt <= DEFAULT_REFLECTION_EMPTY_EVENT_GUARD_TTL_MS) { + if (!resumedFromBeforeReset && guarded && Date.now() - guarded.updatedAt <= DEFAULT_REFLECTION_EMPTY_EVENT_GUARD_TTL_MS) { api.logger.info(`memory-reflection: command:${action} skipped repeated empty/unusable session; sessionKey=${sessionKey}; sessionId=${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}; reason=${guarded.reason}`); return; } @@ -4813,6 +4637,13 @@ const memoryLanceDBProPlugin = { conversation = await readSessionConversationWithResetFallback(currentSessionFile, reflectionMessageCount); } if (!conversation) { + if (isBoundaryAction && !resumedFromBeforeReset) { + // A stale transcript artifact on a migrated host must not hide the + // messages the typed hook is about to supply. + rememberPendingBeforeResetReflection(sessionKey, { event, sessionId: currentSessionId, action }); + api.logger.info(`memory-reflection: command:${action} transcript ${currentSessionFile || "(none)"} holds no usable conversation for session ${currentSessionId}; waiting for the typed before_reset messages`); + return; + } api.logger.warn(`memory-reflection: command:${action} conversation empty/unusable for session ${currentSessionId}; file=${currentSessionFile || "(none)"}`); await rememberEmptyReflectionEvent("empty-conversation"); return; @@ -4850,11 +4681,12 @@ const memoryLanceDBProPlugin = { toolErrorSignals, logger: api.logger, api, // SDK migration Bug 2: pass api for new runtime.agent API + completeText: reflectionCompleteText, }); api.logger.info(`memory-reflection: command:${action} reflection generation done for session ${currentSessionId}; runner=${reflectionGenerated.runner}; usedFallback=${reflectionGenerated.usedFallback ? "yes" : "no"}`); const reflectionText = reflectionGenerated.text; - if (reflectionGenerated.runner === "cli") { - api.logger.warn(`memory-reflection: embedded runner unavailable, used openclaw CLI fallback for session ${currentSessionId}` + + if (reflectionGenerated.runner === "completion") { + api.logger.warn(`memory-reflection: embedded runner unavailable, used the tool-free completion fallback for session ${currentSessionId}` + (reflectionGenerated.error ? ` (${reflectionGenerated.error})` : "")); } else if (reflectionGenerated.usedFallback) { diff --git a/dist/src/llm-client.js b/dist/src/llm-client.js index ef07d62e8..3e3b9bc4b 100644 --- a/dist/src/llm-client.js +++ b/dist/src/llm-client.js @@ -293,6 +293,36 @@ function createHostClient(config, runtimeLlmComplete, log, warnLog) { return null; } }, + async completeText(prompt, label = "generic", systemPrompt, temperature) { + lastError = null; + const messages = []; + if (systemPrompt !== undefined) + messages.push({ role: "system", content: systemPrompt }); + messages.push({ role: "user", content: prompt }); + try { + const result = await raceWithTimeout(runtimeLlmComplete({ + messages, + ...(config.modelExplicit ? { model: config.model } : {}), + temperature: temperature ?? 0.1, + purpose: `memory-lancedb-pro:${label}`, + reasoning: config.thinkLevel?.trim() || DEFAULT_HOST_REASONING_EFFORT, + }), config.timeoutMs); + const text = typeof result?.text === "string" ? result.text.trim() : ""; + if (!text) { + lastError = + `memory-lancedb-pro: llm-client [${label}] empty host-transport response content from model ${config.model}`; + log(lastError); + return null; + } + return text; + } + catch (err) { + lastError = + `memory-lancedb-pro: llm-client [${label}] host-transport request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`; + (warnLog ?? log)(lastError); + return null; + } + }, getLastError() { return lastError; }, @@ -394,6 +424,40 @@ function createApiKeyClient(config, log, warnLog) { return null; } }, + async completeText(prompt, label = "generic", systemPrompt, temperature) { + lastError = null; + try { + const request = { + model: config.model, + messages: [ + ...(systemPrompt !== undefined ? [{ role: "system", content: systemPrompt }] : []), + { role: "user", content: prompt }, + ], + temperature: temperature ?? 0.1, + ...(config.thinkLevel?.trim() + ? { reasoning: { effort: config.thinkLevel.trim() } } + : {}), + }; + const response = await client.chat.completions.create(request, { + headers: { "x-memory-call-label": sanitizeLabelHeader(label) }, + }); + const raw = response.choices?.[0]?.message?.content; + const text = typeof raw === "string" ? raw.trim() : ""; + if (!text) { + lastError = + `memory-lancedb-pro: llm-client [${label}] empty response content from model ${config.model}`; + log(lastError); + return null; + } + return text; + } + catch (err) { + lastError = + `memory-lancedb-pro: llm-client [${label}] request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`; + (warnLog ?? log)(lastError); + return null; + } + }, getLastError() { return lastError; }, @@ -464,26 +528,7 @@ function createOauthClient(config, log, warnLog) { const detail = await response.text().catch(() => ""); throw new Error(`HTTP ${response.status} ${response.statusText}: ${detail.slice(0, 500)}`); } - const bodyText = await response.text(); - const raw = (response.headers.get("content-type")?.includes("text/event-stream") || - looksLikeSseResponse(bodyText)) - ? extractOutputTextFromSse(bodyText) - : (() => { - try { - const parsed = JSON.parse(bodyText); - const output = Array.isArray(parsed.output) ? parsed.output : []; - const first = output.find((item) => item && - typeof item === "object" && - Array.isArray(item.content)); - if (!first) - return null; - const content = first.content.find((part) => part?.type === "output_text" && typeof part.text === "string"); - return typeof content?.text === "string" ? content.text : null; - } - catch { - return null; - } - })(); + const raw = extractOauthOutputText(response, await response.text()); if (!raw) { lastError = `memory-lancedb-pro: llm-client [${label}] empty OAuth response content from model ${config.model}`; @@ -532,11 +577,79 @@ function createOauthClient(config, log, warnLog) { return null; } }, + async completeText(prompt, label = "generic", systemPrompt, _temperature) { + lastError = null; + try { + const session = await getSession(); + const { signal, dispose } = createTimeoutSignal(config.timeoutMs); + const endpoint = buildOauthEndpoint(config.baseURL, config.oauthProvider); + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${session.accessToken}`, + "Content-Type": "application/json", + Accept: "text/event-stream", + "OpenAI-Beta": "responses=experimental", + "chatgpt-account-id": session.accountId, + originator: "codex_cli_rs", + }, + signal, + body: JSON.stringify({ + model: normalizeOauthModel(config.model), + ...(systemPrompt !== undefined ? { instructions: systemPrompt } : {}), + input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }], + store: false, + stream: true, + text: { format: { type: "text" } }, + }), + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(`HTTP ${response.status} ${response.statusText}: ${detail.slice(0, 500)}`); + } + const text = (extractOauthOutputText(response, await response.text()) ?? "").trim(); + if (!text) { + lastError = + `memory-lancedb-pro: llm-client [${label}] empty OAuth response content from model ${config.model}`; + log(lastError); + return null; + } + return text; + } + finally { + dispose(); + } + } + catch (err) { + lastError = + `memory-lancedb-pro: llm-client [${label}] OAuth request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`; + (warnLog ?? log)(lastError); + return null; + } + }, getLastError() { return lastError; }, }; } +function extractOauthOutputText(response, bodyText) { + if (response.headers.get("content-type")?.includes("text/event-stream") || looksLikeSseResponse(bodyText)) { + return extractOutputTextFromSse(bodyText); + } + try { + const parsed = JSON.parse(bodyText); + const output = Array.isArray(parsed.output) ? parsed.output : []; + const first = output.find((item) => item && typeof item === "object" && Array.isArray(item.content)); + if (!first) + return null; + const content = first.content.find((part) => part?.type === "output_text" && typeof part.text === "string"); + return typeof content?.text === "string" ? content.text : null; + } + catch { + return null; + } +} /** OpenRouter's direct API base URL, used as the host->direct fallback's default when llm.baseURL is not configured. */ // Module-level (not per-client) so the "runtime surface unavailable" // warning is emitted once per process even though createLlmClient is diff --git a/index.ts b/index.ts index ce08851ae..7983ea294 100644 --- a/index.ts +++ b/index.ts @@ -11,7 +11,6 @@ import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; import { createRequire } from "node:module"; -import { spawn } from "node:child_process"; import { AsyncLocalStorage } from "node:async_hooks"; // Detect CLI mode: when running as a CLI subcommand (e.g. `openclaw memory-pro stats`), @@ -88,7 +87,7 @@ import { SmartExtractor, createExtractionRateLimiter, stripEnvelopeMetadata } fr import { compressTexts, estimateConversationValue } from "./src/session-compressor.js"; import { NoisePrototypeBank } from "./src/noise-prototypes.js"; import { createLlmClient, normalizeDirectModelRef } from "./src/llm-client.js"; -import type { RuntimeLlmCompleteFn } from "./src/llm-client.js"; +import type { LlmClient, RuntimeLlmCompleteFn } from "./src/llm-client.js"; import { createDecayEngine, DEFAULT_DECAY_CONFIG } from "./src/decay-engine.js"; import { createTierManager, DEFAULT_TIER_CONFIG } from "./src/tier-manager.js"; import { createMemoryUpgrader } from "./src/memory-upgrader.js"; @@ -794,7 +793,8 @@ type ReflectionEmptyEventGuardEntry = { type EmbeddedPiRunner = (params: Record) => Promise; const requireFromHere = createRequire(import.meta.url); -let embeddedPiRunnerPromise: Promise | null = null; +type ResolvedEmbeddedRunner = { runner: EmbeddedPiRunner; exportName: EmbeddedRunnerExportName }; +let embeddedPiRunnerPromise: Promise | null = null; // Circuit breaker for Layer 1: after 3 consecutive failures within 5min, skip Layer 1 const layer1FailureTimestamps: number[] = []; @@ -913,30 +913,25 @@ export function resolveEmbeddedRunnerExportName(candidate: unknown): EmbeddedRun return EMBEDDED_RUNNER_EXPORT_NAMES.find((name) => typeof record[name] === "function"); } -let embeddedRunnerExportName: EmbeddedRunnerExportName | undefined; +let resolvedEmbeddedRunnerKind: EmbeddedRunnerExportName | undefined; export function getEmbeddedRunnerExportName(): EmbeddedRunnerExportName | undefined { - return embeddedRunnerExportName; -} - -// Legacy hosts read the distiller transcript from a file path; current hosts -// treat a non-key sessionFile as a foreign transcript and refuse the run. -function embeddedRunnerTakesSessionFile(): boolean { - return embeddedRunnerExportName !== "runEmbeddedAgent"; + return resolvedEmbeddedRunnerKind; } +// The runner and its kind are cached together: a host surface seen later must +// not relabel an already cached runner (a legacy runner needs the transcript +// file, a current one refuses it). // eslint-disable-next-line import/export -export async function loadEmbeddedPiRunner(api: OpenClawPluginApi): Promise { +export async function loadEmbeddedPiRunner(api: OpenClawPluginApi): Promise { // Layer 1: 嘗試新 SDK API (with circuit breaker) - if (!isLayer1CircuitOpen()) { + if (!embeddedPiRunnerPromise && !isLayer1CircuitOpen()) { const newApi = ((api as unknown as { runtime?: { agent?: Record } }).runtime?.agent); const runnerName = resolveEmbeddedRunnerExportName(newApi); if (newApi && runnerName) { - embeddedRunnerExportName = runnerName; const runner = (newApi[runnerName] as EmbeddedPiRunner).bind(newApi); - // Bug 2 fix: 將 Layer 1 結果寫入 cache,避免後續並發呼叫時 Layer 2 覆蓋掉 Layer 1 - embeddedPiRunnerPromise ??= Promise.resolve(runner as EmbeddedPiRunner); - return embeddedPiRunnerPromise; + resolvedEmbeddedRunnerKind = runnerName; + embeddedPiRunnerPromise = Promise.resolve({ runner, exportName: runnerName }); } } @@ -949,8 +944,8 @@ export async function loadEmbeddedPiRunner(api: OpenClawPluginApi): Promise)[runnerName] as EmbeddedPiRunner; + resolvedEmbeddedRunnerKind = runnerName; + return { runner: (mod as Record)[runnerName] as EmbeddedPiRunner, exportName: runnerName }; } importErrors.push(`${specifier}: runEmbeddedAgent export not found`); } catch (err) { @@ -970,33 +965,11 @@ export async function loadEmbeddedPiRunner(api: OpenClawPluginApi): Promise line.trim().length > 0 && !CLI_STARTUP_NOISE_LINE_RE.test(line)); - const oneLine = lines.join(" ").replace(/\s+/g, " ").trim(); - if (oneLine.length <= maxLen) return oneLine; - return `...${oneLine.slice(oneLine.length - (maxLen - 3))}`; -} - function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -1016,237 +989,6 @@ function withTimeout(promise: Promise, timeoutMs: number, label: string): }); } -function tryParseJsonObject(raw: string): Record | null { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; - } - } catch { - // ignore - } - return null; -} - -function extractJsonObjectFromOutput(stdout: string): Record { - const trimmed = stdout.trim(); - if (!trimmed) throw new Error("empty stdout"); - - const direct = tryParseJsonObject(trimmed); - if (direct) return direct; - - const lines = trimmed.split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - if (!lines[i].trim().startsWith("{")) continue; - const candidate = lines.slice(i).join("\n"); - const parsed = tryParseJsonObject(candidate); - if (parsed) return parsed; - } - - throw new Error(`unable to parse JSON from CLI output: ${clipDiagnostic(trimmed, 280)}`); -} - -export function extractReflectionTextFromCliResult(resultObj: Record): string | null { - const result = resultObj.result as Record | undefined; - const payloads = Array.isArray(resultObj.payloads) - ? resultObj.payloads - : Array.isArray(result?.payloads) - ? result.payloads - : []; - const firstWithText = payloads.find( - (p) => p && typeof p === "object" && typeof (p as Record).text === "string" && ((p as Record).text as string).trim().length - ) as Record | undefined; - const text = typeof firstWithText?.text === "string" ? firstWithText.text.trim() : ""; - if (text) return text; - const finalText = typeof resultObj.final === "string" ? resultObj.final.trim() : ""; - return finalText || null; -} - -type ReflectionCliMode = "exec" | "legacy-local"; - -type ReflectionCliRun = { - stdout: string; - stderr: string; - code: number | null; - signal: NodeJS.Signals | null; - timedOut: boolean; -}; - -// Hosts without `agent exec` reject its argument shape before doing any work. -const LEGACY_CLI_SHAPE_REJECTION_RE = /unknown command|does not recognize|unknown option|too many arguments/i; - -export function buildReflectionCliArgs(params: { - mode: ReflectionCliMode; - agentId: string; - prompt: string; - workspaceDir: string; - thinkLevel: ReflectionThinkLevel; - agentTimeoutSec: number; - sessionId: string; - modelRef?: string; -}): string[] { - if (params.mode === "legacy-local") { - return [ - "agent", - "--local", - "--agent", - params.agentId, - "--message", - params.prompt, - "--json", - "--thinking", - params.thinkLevel, - "--timeout", - String(params.agentTimeoutSec), - "--session-id", - params.sessionId, - ]; - } - const args = [ - "agent", - "exec", - "--message-file", - "-", - "--cwd", - params.workspaceDir, - "--json", - "--thinking", - params.thinkLevel, - "--timeout", - String(params.agentTimeoutSec), - ]; - if (params.modelRef) args.push("--model", params.modelRef); - return args; -} - -export function shouldRetryReflectionCliAsLegacyLocal( - run: Pick, -): boolean { - if (run.timedOut || run.signal || run.code === 0) return false; - return LEGACY_CLI_SHAPE_REJECTION_RE.test(stripAnsi(run.stderr)); -} - -async function spawnReflectionCli(params: { - cliBin: string; - args: string[]; - cwd: string; - outerTimeoutMs: number; - stdinText?: string; -}): Promise { - return await new Promise((resolve, reject) => { - const spawnCommand = buildReflectionCliSpawnCommand(params.cliBin, params.args); - const child = spawn(spawnCommand.command, spawnCommand.args, { - cwd: params.cwd, - env: { ...process.env, NO_COLOR: "1" }, - stdio: [params.stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - let timedOut = false; - - const timer = setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - setTimeout(() => child.kill("SIGKILL"), 1500).unref(); - }, params.outerTimeoutMs); - - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - - if (params.stdinText !== undefined && child.stdin) { - child.stdin.on("error", () => { }); - child.stdin.end(params.stdinText); - } - - child.once("error", (err) => { - if (settled) return; - settled = true; - clearTimeout(timer); - reject(new Error(`spawn ${params.cliBin} failed: ${err.message}`)); - }); - - child.once("close", (code, signal) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve({ stdout, stderr, code, signal, timedOut }); - }); - }); -} - -async function runReflectionViaCli(params: { - prompt: string; - agentId: string; - workspaceDir: string; - timeoutMs: number; - thinkLevel: ReflectionThinkLevel; - modelRef?: string; -}): Promise { - const cliBin = process.env.OPENCLAW_CLI_BIN?.trim() || "openclaw"; - const outerTimeoutMs = Math.max(params.timeoutMs + 5000, 15000); - const agentTimeoutSec = Math.max(1, Math.ceil(params.timeoutMs / 1000)); - const sessionId = `memory-reflection-cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const argParams = { - agentId: params.agentId, - prompt: params.prompt, - workspaceDir: params.workspaceDir, - thinkLevel: params.thinkLevel, - agentTimeoutSec, - sessionId, - modelRef: params.modelRef, - }; - - let run = await spawnReflectionCli({ - cliBin, - args: buildReflectionCliArgs({ ...argParams, mode: "exec" }), - cwd: params.workspaceDir, - outerTimeoutMs, - stdinText: params.prompt, - }); - if (shouldRetryReflectionCliAsLegacyLocal(run)) { - run = await spawnReflectionCli({ - cliBin, - args: buildReflectionCliArgs({ ...argParams, mode: "legacy-local" }), - cwd: params.workspaceDir, - outerTimeoutMs, - }); - } - - if (run.timedOut) throw new Error(`${cliBin} timed out after ${outerTimeoutMs}ms`); - if (run.signal) throw new Error(`${cliBin} exited by signal ${run.signal}. stderr=${clipDiagnosticTail(run.stderr)}`); - if (run.code !== 0) throw new Error(`${cliBin} exited with code ${run.code}. stderr=${clipDiagnosticTail(run.stderr)}`); - - const parsed = extractJsonObjectFromOutput(run.stdout); - const text = extractReflectionTextFromCliResult(parsed); - if (!text) throw new Error(`CLI JSON returned no text payload. stdout=${clipDiagnostic(run.stdout)}`); - return text; -} - -export function buildReflectionCliSpawnCommand( - cliBin: string, - args: string[], - platform: NodeJS.Platform = process.platform, - comSpec = process.env.ComSpec?.trim(), -): { command: string; args: string[] } { - if (platform === "win32") { - return { - command: comSpec || "cmd.exe", - args: ["/c", cliBin, ...args], - }; - } - - return { command: cliBin, args }; -} - async function loadSelfImprovementReminderContent(workspaceDir?: string): Promise { const baseDir = typeof workspaceDir === "string" && workspaceDir.trim().length ? workspaceDir.trim() : ""; if (!baseDir) return DEFAULT_SELF_IMPROVEMENT_REMINDER; @@ -1930,6 +1672,8 @@ type GenerateReflectionTextParams = { toolErrorSignals?: ReflectionErrorSignal[]; logger?: { info?: (message: string) => void; warn?: (message: string) => void }; api: OpenClawPluginApi; // SDK migration Bug 2: pass api to use new runtime.agent API + /** Tool-free completion used when the embedded runner is unavailable. */ + completeText?: (systemPrompt: string, userPrompt: string) => Promise; }; type GenerateReflectionTextResult = { @@ -1937,7 +1681,7 @@ type GenerateReflectionTextResult = { usedFallback: boolean; promptHash: string; error?: string; - runner: "embedded" | "cli" | "fallback"; + runner: "embedded" | "completion" | "fallback"; }; // Model resolution chain: explicit param > agent-specific primary model ref > global llm.model. @@ -2021,7 +1765,6 @@ async function generateReflectionTextUnbounded( else params.logger?.info?.(message); }; const { provider, model } = resolveReflectionModelTarget(params); - const cliModelRef = provider && model ? `${provider}/${model}` : undefined; try { const result: unknown = await runWithReflectionTransientRetryOnce({ @@ -2030,7 +1773,8 @@ async function generateReflectionTextUnbounded( retryState, onLog: onRetryLog, execute: async () => { - const runEmbeddedPiAgent = await loadEmbeddedPiRunner(params.api); + const embedded = await loadEmbeddedPiRunner(params.api); + const runEmbeddedPiAgent = embedded.runner; const embeddedTimeoutMs = Math.max(params.timeoutMs + 5000, 15000); return await withTimeout( @@ -2040,7 +1784,7 @@ async function generateReflectionTextUnbounded( // The distiller run is throwaway: keep it out of the host session store. sessionPersistence: "detached", agentId: params.agentId, - ...(embeddedRunnerTakesSessionFile() ? { sessionFile: tempSessionFile } : {}), + ...(embedded.exportName !== "runEmbeddedAgent" ? { sessionFile: tempSessionFile } : {}), workspaceDir: params.workspaceDir, config: params.cfg, prompt, @@ -2089,23 +1833,25 @@ async function generateReflectionTextUnbounded( return { text: reflectionText, usedFallback: false, promptHash, error: errors[0], runner: "embedded" }; } - try { - reflectionText = await runWithReflectionTransientRetryOnce({ - scope: "reflection", - runner: "cli", - retryState, - onLog: onRetryLog, - execute: async () => await runReflectionViaCli({ - prompt, - agentId: params.agentId, - workspaceDir: params.workspaceDir, - timeoutMs: params.timeoutMs, - thinkLevel: params.thinkLevel, - modelRef: cliModelRef, - }), - }); - } catch (err) { - errors.push(`cli: ${err instanceof Error ? err.message : String(err)}`); + if (params.completeText) { + const completeText = params.completeText; + try { + reflectionText = await runWithReflectionTransientRetryOnce({ + scope: "reflection", + runner: "completion", + retryState, + onLog: onRetryLog, + execute: async () => { + const text = await completeText(reflectionSystemPrompt, reflectionUserPrompt); + if (!text) throw new Error("completion returned no text"); + return text; + }, + }); + } catch (err) { + errors.push(`completion: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + errors.push("completion: no tool-free completion client on this host"); } if (reflectionText) { @@ -2114,7 +1860,7 @@ async function generateReflectionTextUnbounded( usedFallback: false, promptHash, error: errors.length > 0 ? errors.join(" | ") : undefined, - runner: "cli", + runner: "completion", }; } @@ -2670,6 +2416,7 @@ interface PluginSingletonState { captureAdmissionController: () => AdmissionController | null; captureAdmissionAudit: () => boolean; captureReflectionAdmissionController: () => AdmissionController | null; + makeLaneLlmClient: (model: string, thinkLevel?: string, modelExplicit?: boolean) => LlmClient; admissionRejectionAuditWriter: ((entry: AdmissionRejectionAuditEntry) => Promise) | null; } @@ -2986,6 +2733,10 @@ function _initPluginState(api: OpenClawPluginApi): PluginSingletonState { const captureAdmissionAudit = () => admissionController !== null && config.admissionControl?.auditMetadata !== false; const captureReflectionAdmissionController = () => admissionControllerReflectionLane; + const makeLaneLlmClient = (model: string, thinkLevel?: string, modelExplicit?: boolean) => { + const { makeClientForModel, llmModelExplicit } = buildMemoryLlmClient(); + return makeClientForModel(model, thinkLevel, modelExplicit ?? llmModelExplicit); + }; const extractionRateLimiter = createExtractionRateLimiter({ maxExtractionsPerHour: config.extractionThrottle?.maxExtractionsPerHour, @@ -3046,6 +2797,7 @@ function _initPluginState(api: OpenClawPluginApi): PluginSingletonState { captureAdmissionController, captureAdmissionAudit, captureReflectionAdmissionController, + makeLaneLlmClient, admissionRejectionAuditWriter, }; } @@ -3207,6 +2959,7 @@ const memoryLanceDBProPlugin = { captureAdmissionController, captureAdmissionAudit, captureReflectionAdmissionController, + makeLaneLlmClient, admissionRejectionAuditWriter, } = singleton; @@ -5644,6 +5397,29 @@ const memoryLanceDBProPlugin = { const reflectionMaxConcurrentRuns = config.memoryReflection?.maxConcurrentRuns ?? DEFAULT_REFLECTION_MAX_CONCURRENT_RUNS; const reflectionAgentId = asNonEmptyString(config.memoryReflection?.agentId); const reflectionModel = asNonEmptyString(config.memoryReflection?.model); + // Tool-free fallback for the distiller when the embedded runner is + // unavailable: a plain completion on the plugin's own LLM lane, so the + // transcript never reaches an agent turn that could invoke tools. + let reflectionCompletionClient: LlmClient | null | undefined; + const reflectionCompleteText = async (systemPrompt: string, userPrompt: string): Promise => { + if (reflectionCompletionClient === undefined) { + const model = reflectionModel ?? asNonEmptyString(config.llm?.model); + try { + reflectionCompletionClient = model + ? makeLaneLlmClient( + config.llm?.transport === "host" ? model.trim() : normalizeDirectModelRef(model), + reflectionThinkLevel, + reflectionModel ? true : undefined, + ) + : null; + } catch (err) { + api.logger.warn(`memory-reflection: completion fallback unavailable: ${err instanceof Error ? err.message : String(err)}`); + reflectionCompletionClient = null; + } + } + if (!reflectionCompletionClient) return null; + return reflectionCompletionClient.completeText(userPrompt, "memory-reflection", systemPrompt); + }; const reflectionErrorReminderMaxEntries = parsePositiveInt(config.memoryReflection?.errorReminderMaxEntries) ?? DEFAULT_REFLECTION_ERROR_REMINDER_MAX_ENTRIES; const reflectionDedupeErrorSignals = config.memoryReflection?.dedupeErrorSignals !== false; @@ -5965,7 +5741,7 @@ const memoryLanceDBProPlugin = { sessionFile: currentSessionFile, }); const guarded = getReflectionEmptyEventGuardMap().get(emptyEventGuardKey); - if (guarded && Date.now() - guarded.updatedAt <= DEFAULT_REFLECTION_EMPTY_EVENT_GUARD_TTL_MS) { + if (!resumedFromBeforeReset && guarded && Date.now() - guarded.updatedAt <= DEFAULT_REFLECTION_EMPTY_EVENT_GUARD_TTL_MS) { api.logger.info( `memory-reflection: command:${action} skipped repeated empty/unusable session; sessionKey=${sessionKey}; sessionId=${currentSessionId}; sessionFile=${currentSessionFile || "(none)"}; reason=${guarded.reason}` ); @@ -6083,6 +5859,15 @@ const memoryLanceDBProPlugin = { conversation = await readSessionConversationWithResetFallback(currentSessionFile, reflectionMessageCount); } if (!conversation) { + if (isBoundaryAction && !resumedFromBeforeReset) { + // A stale transcript artifact on a migrated host must not hide the + // messages the typed hook is about to supply. + rememberPendingBeforeResetReflection(sessionKey, { event, sessionId: currentSessionId, action }); + api.logger.info( + `memory-reflection: command:${action} transcript ${currentSessionFile || "(none)"} holds no usable conversation for session ${currentSessionId}; waiting for the typed before_reset messages` + ); + return; + } api.logger.warn( `memory-reflection: command:${action} conversation empty/unusable for session ${currentSessionId}; file=${currentSessionFile || "(none)"}` ); @@ -6127,14 +5912,15 @@ const memoryLanceDBProPlugin = { toolErrorSignals, logger: api.logger, api, // SDK migration Bug 2: pass api for new runtime.agent API + completeText: reflectionCompleteText, }); api.logger.info( `memory-reflection: command:${action} reflection generation done for session ${currentSessionId}; runner=${reflectionGenerated.runner}; usedFallback=${reflectionGenerated.usedFallback ? "yes" : "no"}` ); const reflectionText = reflectionGenerated.text; - if (reflectionGenerated.runner === "cli") { + if (reflectionGenerated.runner === "completion") { api.logger.warn( - `memory-reflection: embedded runner unavailable, used openclaw CLI fallback for session ${currentSessionId}` + + `memory-reflection: embedded runner unavailable, used the tool-free completion fallback for session ${currentSessionId}` + (reflectionGenerated.error ? ` (${reflectionGenerated.error})` : "") ); } else if (reflectionGenerated.usedFallback) { diff --git a/src/llm-client.ts b/src/llm-client.ts index 01a5056fc..e3ff638df 100644 --- a/src/llm-client.ts +++ b/src/llm-client.ts @@ -127,6 +127,13 @@ export interface LlmClient { * and ignores this argument. */ completeJson(prompt: string, label?: string, systemPrompt?: string, temperature?: number): Promise; + /** + * Send a prompt and return the model's answer as trimmed text. No JSON + * expectation is attached and `systemPrompt` is sent verbatim (no system + * message at all when omitted). Returns null on a transport failure or an + * empty answer; `getLastError` carries the reason. + */ + completeText(prompt: string, label?: string, systemPrompt?: string, temperature?: number): Promise; /** Best-effort diagnostics for the most recent failure, if any. */ getLastError(): string | null; } @@ -425,6 +432,37 @@ function createHostClient( return null; } }, + async completeText(prompt: string, label = "generic", systemPrompt?: string, temperature?: number): Promise { + lastError = null; + const messages: RuntimeLlmCompleteMessage[] = []; + if (systemPrompt !== undefined) messages.push({ role: "system", content: systemPrompt }); + messages.push({ role: "user", content: prompt }); + try { + const result = await raceWithTimeout( + runtimeLlmComplete({ + messages, + ...(config.modelExplicit ? { model: config.model } : {}), + temperature: temperature ?? 0.1, + purpose: `memory-lancedb-pro:${label}`, + reasoning: config.thinkLevel?.trim() || DEFAULT_HOST_REASONING_EFFORT, + }), + config.timeoutMs, + ); + const text = typeof result?.text === "string" ? result.text.trim() : ""; + if (!text) { + lastError = + `memory-lancedb-pro: llm-client [${label}] empty host-transport response content from model ${config.model}`; + log(lastError); + return null; + } + return text; + } catch (err) { + lastError = + `memory-lancedb-pro: llm-client [${label}] host-transport request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`; + (warnLog ?? log)(lastError); + return null; + } + }, getLastError(): string | null { return lastError; }, @@ -534,6 +572,39 @@ function createApiKeyClient(config: LlmClientConfig, log: (msg: string) => void, return null; } }, + async completeText(prompt: string, label = "generic", systemPrompt?: string, temperature?: number): Promise { + lastError = null; + try { + const request = { + model: config.model, + messages: [ + ...(systemPrompt !== undefined ? [{ role: "system", content: systemPrompt }] : []), + { role: "user", content: prompt }, + ], + temperature: temperature ?? 0.1, + ...(config.thinkLevel?.trim() + ? { reasoning: { effort: config.thinkLevel.trim() } } + : {}), + }; + const response = await client.chat.completions.create(request as any, { + headers: { "x-memory-call-label": sanitizeLabelHeader(label) }, + }); + const raw = response.choices?.[0]?.message?.content; + const text = typeof raw === "string" ? raw.trim() : ""; + if (!text) { + lastError = + `memory-lancedb-pro: llm-client [${label}] empty response content from model ${config.model}`; + log(lastError); + return null; + } + return text; + } catch (err) { + lastError = + `memory-lancedb-pro: llm-client [${label}] request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`; + (warnLog ?? log)(lastError); + return null; + } + }, getLastError(): string | null { return lastError; }, @@ -610,31 +681,7 @@ function createOauthClient(config: LlmClientConfig, log: (msg: string) => void, throw new Error(`HTTP ${response.status} ${response.statusText}: ${detail.slice(0, 500)}`); } - const bodyText = await response.text(); - const raw = ( - response.headers.get("content-type")?.includes("text/event-stream") || - looksLikeSseResponse(bodyText) - ) - ? extractOutputTextFromSse(bodyText) - : (() => { - try { - const parsed = JSON.parse(bodyText) as Record; - const output = Array.isArray(parsed.output) ? parsed.output : []; - const first = output.find( - (item) => - item && - typeof item === "object" && - Array.isArray((item as Record).content), - ) as Record | undefined; - if (!first) return null; - const content = (first.content as Array>).find( - (part) => part?.type === "output_text" && typeof part.text === "string", - ); - return typeof content?.text === "string" ? content.text : null; - } catch { - return null; - } - })(); + const raw = extractOauthOutputText(response, await response.text()); if (!raw) { lastError = @@ -684,12 +731,81 @@ function createOauthClient(config: LlmClientConfig, log: (msg: string) => void, return null; } }, + async completeText(prompt: string, label = "generic", systemPrompt?: string, _temperature?: number): Promise { + lastError = null; + try { + const session = await getSession(); + const { signal, dispose } = createTimeoutSignal(config.timeoutMs); + const endpoint = buildOauthEndpoint(config.baseURL, config.oauthProvider); + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${session.accessToken}`, + "Content-Type": "application/json", + Accept: "text/event-stream", + "OpenAI-Beta": "responses=experimental", + "chatgpt-account-id": session.accountId, + originator: "codex_cli_rs", + }, + signal, + body: JSON.stringify({ + model: normalizeOauthModel(config.model), + ...(systemPrompt !== undefined ? { instructions: systemPrompt } : {}), + input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }], + store: false, + stream: true, + text: { format: { type: "text" } }, + }), + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(`HTTP ${response.status} ${response.statusText}: ${detail.slice(0, 500)}`); + } + const text = (extractOauthOutputText(response, await response.text()) ?? "").trim(); + if (!text) { + lastError = + `memory-lancedb-pro: llm-client [${label}] empty OAuth response content from model ${config.model}`; + log(lastError); + return null; + } + return text; + } finally { + dispose(); + } + } catch (err) { + lastError = + `memory-lancedb-pro: llm-client [${label}] OAuth request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`; + (warnLog ?? log)(lastError); + return null; + } + }, getLastError(): string | null { return lastError; }, }; } +function extractOauthOutputText(response: Response, bodyText: string): string | null { + if (response.headers.get("content-type")?.includes("text/event-stream") || looksLikeSseResponse(bodyText)) { + return extractOutputTextFromSse(bodyText); + } + try { + const parsed = JSON.parse(bodyText) as Record; + const output = Array.isArray(parsed.output) ? parsed.output : []; + const first = output.find( + (item) => item && typeof item === "object" && Array.isArray((item as Record).content), + ) as Record | undefined; + if (!first) return null; + const content = (first.content as Array>).find( + (part) => part?.type === "output_text" && typeof part.text === "string", + ); + return typeof content?.text === "string" ? content.text : null; + } catch { + return null; + } +} + /** OpenRouter's direct API base URL, used as the host->direct fallback's default when llm.baseURL is not configured. */ // Module-level (not per-client) so the "runtime surface unavailable" // warning is emitted once per process even though createLlmClient is diff --git a/test/command-reflection-guard.test.mjs b/test/command-reflection-guard.test.mjs index ad248717b..bed9dfcf6 100644 --- a/test/command-reflection-guard.test.mjs +++ b/test/command-reflection-guard.test.mjs @@ -236,6 +236,11 @@ describe("runMemoryReflection — invalid agentId guard", () => { hook.meta?.name === "memory-lancedb-pro.memory-reflection.command-new" ); assert.ok(reflectionHook, "expected memory reflection command:new hook"); + // An empty transcript file parks the boundary for the typed before_reset + // hook; the empty guard is recorded when that hook brings no messages. + const beforeResetHooks = harness.eventHandlers.get("before_reset") || []; + assert.equal(beforeResetHooks.length, 1, "expected the reflection before_reset listener"); + const beforeResetHook = beforeResetHooks[0]; const emptySessionFile = path.join(workDir, "fresh-empty.jsonl"); writeFileSync(emptySessionFile, "", "utf-8"); @@ -258,14 +263,20 @@ describe("runMemoryReflection — invalid agentId guard", () => { }, }, }, { sessionKey: "agent:main:session:fresh", agentId: "main" }); + await beforeResetHook.handler( + { sessionFile: emptySessionFile, messages: [], reason: "new" }, + { agentId: "main", sessionKey: "agent:main:session:fresh", sessionId: "fresh-empty", workspaceDir: workDir }, + ); now += 10; } } finally { Date.now = originalDateNow; } + const parkedLogs = harness.logs.filter(([, msg]) => msg.includes("waiting for the typed before_reset messages")); + assert.equal(parkedLogs.length, 1, `only the first empty event should park for before_reset; got ${JSON.stringify(parkedLogs)}`); const emptyLogs = harness.logs.filter(([, msg]) => msg.includes("conversation empty/unusable")); - assert.equal(emptyLogs.length, 1, `only the first empty event should read the session; got ${JSON.stringify(emptyLogs)}`); + assert.equal(emptyLogs.length, 1, `only the first before_reset continuation should judge the session empty; got ${JSON.stringify(emptyLogs)}`); const skippedLogs = harness.logs.filter(([, msg]) => msg.includes("skipped repeated empty/unusable session")); assert.equal(skippedLogs.length, 2, `expected repeated empty events to hit the guard; got ${JSON.stringify(harness.logs)}`); diff --git a/test/memory-reflection.test.mjs b/test/memory-reflection.test.mjs index 111a442fd..3cd40a411 100644 --- a/test/memory-reflection.test.mjs +++ b/test/memory-reflection.test.mjs @@ -299,7 +299,7 @@ describe("memory reflection", () => { await assert.rejects( runWithReflectionTransientRetryOnce({ scope: "reflection", - runner: "cli", + runner: "completion", retryState, execute: async () => { attempts += 1; @@ -322,7 +322,7 @@ describe("memory reflection", () => { await assert.rejects( runWithReflectionTransientRetryOnce({ scope: "distiller", - runner: "cli", + runner: "completion", retryState, execute: async () => { attempts += 1; diff --git a/test/reflection-before-reset-transcript.test.mjs b/test/reflection-before-reset-transcript.test.mjs index a8b614f43..60b7b6775 100644 --- a/test/reflection-before-reset-transcript.test.mjs +++ b/test/reflection-before-reset-transcript.test.mjs @@ -13,7 +13,7 @@ */ import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "os"; import path from "path"; import { fileURLToPath } from "node:url"; @@ -241,4 +241,20 @@ describe("reflection finishes from the typed before_reset messages", () => { assert.equal(storeSeenByRunner, undefined, "the embedded runner must not see the caller's released root context"); assert.ok(harness.logs.some((m) => m.includes("reflection generation start for session escaped")), JSON.stringify(harness.logs)); }); + + it("parks when the recovered transcript file holds no usable conversation, then finishes from before_reset", async () => { + const setup = registered(); + const staleFile = path.join(workDir, "stale-session.jsonl"); + writeFileSync(staleFile, JSON.stringify({ type: "session-meta", version: 1 }) + "\n", "utf-8"); + const sessionKey = await fireCommandNew(setup, "stalefile", { previousSessionEntry: { sessionId: "stalefile", sessionFile: staleFile } }); + const { logs } = setup.harness; + assert.ok( + logs.some((m) => m.includes("holds no usable conversation for session stalefile; waiting for the typed before_reset messages")), + `got ${JSON.stringify(logs)}`, + ); + assert.ok(!logs.some((m) => m.includes("empty/unusable guard recorded")), "a stale artifact must not record the empty guard"); + await fireBeforeReset(setup, sessionKey, "stalefile"); + assert.ok(logs.some((m) => m.includes("using the before_reset transcript for session stalefile; messages=present")), JSON.stringify(logs)); + assert.equal(logs.filter((m) => m.includes("reflection generation start for session stalefile")).length, 1, JSON.stringify(logs)); + }); }); diff --git a/test/reflection-runner-embedded-agent.test.mjs b/test/reflection-runner-embedded-agent.test.mjs index e6a350ac1..e5e134ac1 100644 --- a/test/reflection-runner-embedded-agent.test.mjs +++ b/test/reflection-runner-embedded-agent.test.mjs @@ -29,13 +29,7 @@ function loadFreshIndex() { return jiti("../index.ts"); } -const { - resolveEmbeddedRunnerExportName, - buildReflectionCliArgs, - shouldRetryReflectionCliAsLegacyLocal, - clipDiagnosticTail, - extractReflectionTextFromCliResult, -} = loadFreshIndex(); +const { resolveEmbeddedRunnerExportName } = loadFreshIndex(); const noop = async () => ({ payloads: [{ text: "noop" }] }); @@ -132,100 +126,115 @@ describe("reflection distiller on a renamed-runner host", () => { }); }); -describe("CLI fallback argument shape", () => { - const base = { - agentId: "agent-one", - prompt: "line one\nline two", - workspaceDir: "/tmp/workspace-one", - thinkLevel: "low", - agentTimeoutSec: 30, - sessionId: "memory-reflection-cli-1", +describe("tool-free completion fallback", () => { + const conversation = "user: the build is green\nassistant: noted"; + const failingApi = { + runtime: { + agent: { + runEmbeddedAgent: async () => { + throw new Error("embedded runner refused"); + }, + }, + }, }; - it("drives a headless exec turn and never asks for --local", () => { - const args = buildReflectionCliArgs({ ...base, mode: "exec", modelRef: "openrouter/example/model-one" }); - assert.deepEqual(args.slice(0, 2), ["agent", "exec"]); - assert.ok(!args.includes("--local"), `--local must be absent: ${JSON.stringify(args)}`); - assert.ok(!args.includes("--agent"), "exec has no agent selector"); - assert.ok(!args.includes(base.prompt), "the prompt travels over stdin, not argv"); - assert.deepEqual(args.slice(args.indexOf("--message-file"), args.indexOf("--message-file") + 2), ["--message-file", "-"]); - assert.deepEqual(args.slice(args.indexOf("--cwd"), args.indexOf("--cwd") + 2), ["--cwd", base.workspaceDir]); - assert.ok(args.includes("--json")); - assert.deepEqual(args.slice(args.indexOf("--thinking"), args.indexOf("--thinking") + 2), ["--thinking", "low"]); - assert.deepEqual(args.slice(args.indexOf("--timeout"), args.indexOf("--timeout") + 2), ["--timeout", "30"]); - assert.deepEqual(args.slice(args.indexOf("--model"), args.indexOf("--model") + 2), ["--model", "openrouter/example/model-one"]); - }); - - it("omits --model when no provider-qualified ref resolved", () => { - const args = buildReflectionCliArgs({ ...base, mode: "exec" }); - assert.ok(!args.includes("--model")); - }); - - it("keeps the legacy --local shape for hosts that predate agent exec", () => { - const args = buildReflectionCliArgs({ ...base, mode: "legacy-local" }); - assert.deepEqual(args, [ - "agent", - "--local", - "--agent", - "agent-one", - "--message", - base.prompt, - "--json", - "--thinking", - "low", - "--timeout", - "30", - "--session-id", - "memory-reflection-cli-1", - ]); - }); -}); - -describe("legacy retry decision", () => { - const run = (stderr, code = 1) => ({ stderr, code, signal: null, timedOut: false }); - - it("retries only when the host rejected the exec argument shape", () => { - assert.equal(shouldRetryReflectionCliAsLegacyLocal(run("error: unknown command 'exec'")), true); - assert.equal(shouldRetryReflectionCliAsLegacyLocal(run("error: too many arguments for 'agent'. Expected 0 arguments but got 1.")), true); - assert.equal(shouldRetryReflectionCliAsLegacyLocal(run('\u001b[31mOpenClaw does not recognize option "--message-file".\u001b[39m')), true); - }); - - it("does not retry runtime failures, successes, signals or timeouts", () => { - assert.equal(shouldRetryReflectionCliAsLegacyLocal(run("A Gateway is running for this state directory (pid 1, port 2).")), false); - assert.equal(shouldRetryReflectionCliAsLegacyLocal(run("error: unknown command 'exec'", 0)), false); - assert.equal(shouldRetryReflectionCliAsLegacyLocal({ stderr: "error: unknown command 'exec'", code: null, signal: "SIGTERM", timedOut: false }), false); - assert.equal(shouldRetryReflectionCliAsLegacyLocal({ stderr: "error: unknown command 'exec'", code: null, signal: null, timedOut: true }), false); + it("hands the reflection prompts to the completion when the embedded runner fails", async () => { + const { generateReflectionText } = loadFreshIndex(); + const seen = []; + const result = await generateReflectionText({ + conversation, + maxInputChars: 1000, + cfg: {}, + agentId: "agent-one", + workspaceDir: "/tmp", + timeoutMs: 2000, + thinkLevel: "off", + api: failingApi, + completeText: async (systemPrompt, userPrompt) => { + seen.push({ systemPrompt, userPrompt }); + return " distilled by completion "; + }, + }); + assert.equal(result.runner, "completion"); + assert.equal(result.usedFallback, false); + assert.equal(result.text, " distilled by completion "); + assert.equal(seen.length, 1, "one completion call"); + assert.ok(seen[0].systemPrompt.length > 0, "the distiller system prompt travels as the system message"); + assert.ok(seen[0].userPrompt.includes("the build is green"), "the transcript travels in the user prompt"); + assert.match(result.error ?? "", /embedded runner refused/); }); -}); -describe("CLI diagnostic clipping", () => { - it("drops state-migration banners and ANSI noise, keeping the failure reason", () => { - const stderr = [ - "\u001b[33m[state-migrations]\u001b[39m legacy allowFrom file left in place: /tmp/one.json", - "[state-migrations] legacy allowFrom file left in place: /tmp/two.json", - "", - "A Gateway is running for this state directory (pid 1, port 2). Run without --local to use it.", - ].join("\n"); - const clipped = clipDiagnosticTail(stderr); - assert.ok(!clipped.includes("state-migrations"), clipped); - assert.ok(!clipped.includes("\u001b["), clipped); - assert.ok(clipped.includes("Run without --local to use it."), clipped); + it("falls through to the static fallback text when the completion returns nothing", async () => { + const { generateReflectionText } = loadFreshIndex(); + const result = await generateReflectionText({ + conversation, + maxInputChars: 1000, + cfg: {}, + agentId: "agent-one", + workspaceDir: "/tmp", + timeoutMs: 2000, + thinkLevel: "off", + api: failingApi, + completeText: async () => null, + }); + assert.equal(result.runner, "fallback"); + assert.equal(result.usedFallback, true); + assert.match(result.error ?? "", /completion returned no text/); }); - it("keeps the tail when the text is longer than the budget", () => { - const filler = "banner ".repeat(200); - const clipped = clipDiagnosticTail(`${filler}final reason here`, 60); - assert.ok(clipped.startsWith("..."), clipped); - assert.ok(clipped.endsWith("final reason here"), clipped); - assert.equal(clipped.length, 60); + it("reports the missing completion client instead of reaching for a CLI", async () => { + const { generateReflectionText } = loadFreshIndex(); + const result = await generateReflectionText({ + conversation, + maxInputChars: 1000, + cfg: {}, + agentId: "agent-one", + workspaceDir: "/tmp", + timeoutMs: 2000, + thinkLevel: "off", + api: failingApi, + }); + assert.equal(result.runner, "fallback"); + assert.match(result.error ?? "", /no tool-free completion client/); }); }); -describe("CLI result extraction", () => { - it("reads the exec envelope payloads and falls back to final", () => { - assert.equal(extractReflectionTextFromCliResult({ ok: true, payloads: [{ text: " from payloads " }], final: "from final" }), "from payloads"); - assert.equal(extractReflectionTextFromCliResult({ ok: true, payloads: [], final: " from final " }), "from final"); - assert.equal(extractReflectionTextFromCliResult({ result: { payloads: [{ text: "legacy envelope" }] } }), "legacy envelope"); - assert.equal(extractReflectionTextFromCliResult({ ok: true, payloads: [] }), null); +describe("embedded runner cache", () => { + it("keeps the runner and its kind together across host surfaces", async () => { + const { generateReflectionText, getEmbeddedRunnerExportName } = loadFreshIndex(); + const legacyParams = []; + let currentCalls = 0; + const legacyApi = { + runtime: { + agent: { + runEmbeddedPiAgent: async (params) => { + legacyParams.push(params); + return { payloads: [{ text: "legacy" }] }; + }, + }, + }, + }; + const currentApi = { + runtime: { + agent: { + runEmbeddedAgent: async () => { + currentCalls += 1; + return { payloads: [{ text: "current" }] }; + }, + }, + }, + }; + const base = { conversation: "user: hi\nassistant: hello", maxInputChars: 1000, cfg: {}, agentId: "agent-one", workspaceDir: "/tmp", timeoutMs: 2000, thinkLevel: "off" }; + + const first = await generateReflectionText({ ...base, api: legacyApi }); + assert.equal(first.text, "legacy"); + assert.equal(getEmbeddedRunnerExportName(), "runEmbeddedPiAgent"); + assert.equal(typeof legacyParams[0].sessionFile, "string", "the legacy runner gets its transcript file"); + + const second = await generateReflectionText({ ...base, api: currentApi }); + assert.equal(second.text, "legacy", "the cached runner keeps serving"); + assert.equal(currentCalls, 0, "a later host surface does not replace the cached runner"); + assert.equal(getEmbeddedRunnerExportName(), "runEmbeddedPiAgent", "the cached kind stays with the cached runner"); + assert.equal(typeof legacyParams[1].sessionFile, "string", "the second run is still labeled legacy and keeps the transcript file"); }); }); diff --git a/test/windows-reflection-fallback.test.mjs b/test/windows-reflection-fallback.test.mjs index 4e4615f8b..d9f0ee6d7 100644 --- a/test/windows-reflection-fallback.test.mjs +++ b/test/windows-reflection-fallback.test.mjs @@ -16,7 +16,6 @@ const jiti = jitiFactory(import.meta.url, { const { toImportSpecifier, getExtensionApiImportSpecifiers, - buildReflectionCliSpawnCommand, } = jiti("../index.ts"); describe("Windows reflection fallback helpers", () => { @@ -58,20 +57,5 @@ describe("Windows reflection fallback helpers", () => { assert.ok(specifiers.some((s) => s.includes("Program%20Files/nodejs")), `Expected Program Files fallback: ${JSON.stringify(specifiers)}`); }); - it("spawns the OpenClaw CLI directly on POSIX platforms", () => { - const command = buildReflectionCliSpawnCommand("openclaw", ["agent", "--json"], "linux"); - assert.equal(command.command, "openclaw"); - assert.deepEqual(command.args, ["agent", "--json"]); - }); - it("resolves the OpenClaw CLI through cmd on Windows", () => { - const command = buildReflectionCliSpawnCommand( - "openclaw", - ["agent", "--json"], - "win32", - "C:\\Windows\\System32\\cmd.exe", - ); - assert.equal(command.command, "C:\\Windows\\System32\\cmd.exe"); - assert.deepEqual(command.args, ["/c", "openclaw", "agent", "--json"]); - }); });