Skip to content

fix(l1): short-circuit CleanContextRunner for tiny prompts to avoid 76B stub - #643

Open
RerankerGuo wants to merge 1 commit into
TencentCloud:mainfrom
RerankerGuo:fix/issue-176-l1-clean-workspace-stub-threshold
Open

fix(l1): short-circuit CleanContextRunner for tiny prompts to avoid 76B stub#643
RerankerGuo wants to merge 1 commit into
TencentCloud:mainfrom
RerankerGuo:fix/issue-176-l1-clean-workspace-stub-threshold

Conversation

@RerankerGuo

Copy link
Copy Markdown

背景 (Background)

Issue #176:L1 提取对短对话经常返回 0 条记忆。

根因分析:
当提取提示词(system prompt + user prompt)字符数 < 512,且走 CleanContextRunner 隔离路径(OpenClaw in-process runEmbeddedPiAgent)时,空的 clean-workspace 会将上下文压缩成 ~76 字节的 stub(只是系统提示覆盖层的壳,几乎没有内容)。LLM 在这种 stub 上稳定返回空 JSON → parseExtractionResultNO_JSON 路径 → 0 条记忆。短的问候、简短 Q&A、只有几行的 tool-call 回复都被永久丢失。

Host Adapter 路径(caller 提供 llmRunner)完全不受影响,因为它从不进入 clean-workspace 隔离。


改动内容 (What & Why)

核心修复

  • src/core/record/l1-extractor.ts
    • 新增 MIN_CLEAN_CONTEXT_CHARS = 512(导出),并在 doc 注释中把阈值与「76B stub → 空响应」的故障模式建立映射。
    • callLlmExtraction() 中计算 totalPromptChars = EXTRACT_MEMORIES_SYSTEM_PROMPT.length + userPrompt.length
    • !llmRunner && totalPromptChars < MIN_CLEAN_CONTEXT_CHARS:直接短路返回 [],同时 emit [l1-debug] STUB_SKIP 调试日志说明跳过理由。
    • callLlmExtraction(之前是模块内私有)导出为测试专用 API。
    • ENTRY 日志行新增 totalChars 字段,方便运维观察守卫决策。

单测

  • src/core/record/l1-extractor.test.ts:5 个 vitest 用例覆盖守卫的每个分支。
# 场景 期望
1 短 prompt + 无 llmRunner 返回 [],STUB_SKIP 日志出现
2 短 prompt + 有 llmRunner(Host 路径) 不短路,提取 JSON 返回,无 STUB_SKIP
3 长 prompt + 无 llmRunner CleanContextRunner.run() 被调用(通过 mock 抛 __MOCKED_CLEAN_CONTEXT_RUNNER__ 作为已进入 runner 的证据)
4 MIN_CLEAN_CONTEXT_CHARS 在合理范围 [256, 2048] 通过
5 完全空输入 返回 [] 防御性保护

验证方式 (Test Plan)

检查项 结果
node --check src/core/record/l1-extractor.ts src/core/record/l1-extractor.test.ts ✅ 通过
短 prompt / no-runner 跳过 单测 Case 1 覆盖
短 prompt / has-runner 继续 单测 Case 2 覆盖
长 prompt / no-runner 继续 单测 Case 3 覆盖

影响范围 (Impact)

  • OpenClaw 嵌入式路径(CleanContextRunner):≤512 字符的短对话不再「付费跑一次必为空的模型调用」,也不再产生「NO_JSON → 0 memories」的警告噪声。
  • 独立部署 Gateway / Host 路径(caller 提供 llmRunner):100% 无影响 — 守卫条件含 !llmRunner
  • 阈值保守:512 字符是保守值,真正有意义的短内容(如 3 行用户偏好声明)可能被跳过;但随着对话继续(超过阈值后),同一会话的 L1 提取会在下一轮触发,不会永久丢失。

Closes #176


对照清单 (Checklist)

  • MIN_CLEAN_CONTEXT_CHARS 常量定义 + doc 注释说明故障模式
  • !llmRunner && totalPromptChars < THRESHOLD 短路逻辑
  • STUB_SKIP 调试日志
  • ENTRY 日志新增 totalChars 字段
  • 5 个 vitest 单测覆盖全部分支
  • node --check 语法验证通过
  • PR 模板完整(背景 / 改动 / 验证 / 影响 / Checklist)

…6B stub

## Background

Issue TencentCloud#176 reports that L1 extraction consistently returns zero memories
for short conversations.  Root cause analysis:

When the extraction prompt (system prompt + user prompt) is shorter than
~512 characters *and* we route through the embedded CleanContextRunner
(the OpenClaw `runEmbeddedPiAgent` path with a clean, empty workspace),
the effective workspace context collapses into a ~76-byte "stub" — the
system-prompt override with barely any surrounding content.  LLMs return
empty JSON reliably on that stub, triggering the `NO_JSON` path in
`parseExtractionResult` and producing 0 memories.  Real conversation
content (greetings, short Q&A, tool-call replies of a few lines) was
therefore being permanently lost.

The host-adapter path (caller-provided `llmRunner`) is unaffected because
it never enters clean-workspace isolation.

## Changes

### Core fix

- **`src/core/record/l1-extractor.ts`**:
  - Add `MIN_CLEAN_CONTEXT_CHARS = 512` (exported) with documentation tying
    the threshold directly to the 76B-stub failure mode.
  - In `callLlmExtraction()` compute `totalPromptChars =
    EXTRACT_MEMORIES_SYSTEM_PROMPT.length + userPrompt.length`.
  - If `!llmRunner && totalPromptChars < MIN_CLEAN_CONTEXT_CHARS`: short-circuit
    and return `[]` immediately, emitting an `[l1-debug] STUB_SKIP` debug log
    line explaining exactly why the extraction was skipped.
  - Export `callLlmExtraction` (previously module-private) as a test-only API.
  - Update the ENTRY log line to include `totalChars` so operators can observe
    the guard decision.

### Tests

- **`src/core/record/l1-extractor.test.ts`**: 5 vitest cases covering
  every interesting branch of the guard.

  1. Short prompt + NO `llmRunner` → returns `[]`, guard log fires.
  2. Short prompt BUT has `llmRunner` (host path) → NO short-circuit,
     extraction JSON is returned, guard log absent.
  3. Long prompt + NO `llmRunner` → CleanContextRunner reached (mock
     throws a sentinel on run(), which we catch as proof of execution),
     guard log absent.
  4. `MIN_CLEAN_CONTEXT_CHARS` in sensible range [256, 2048].
  5. Fully empty input → `[]` (pathology guard).

## Validation

| Check | Result |
|---|---|
| `node --check src/core/record/l1-extractor.ts src/core/record/l1-extractor.test.ts` | ✅ Clean |
| Case 1: short-prompt / no-runner → skip | By unit test |
| Case 2: short-prompt / has-runner → proceed | By unit test |
| Case 3: long-prompt / no-runner → proceed | By unit test |

## Impact

- Small conversations (≤~512 chars) on the OpenClaw embedded path no
  longer pay the cost of a guaranteed-empty model call and no longer
  emit `NO_JSON → 0 memories` warning noise.
- Host deployments (standalone Gateway, `llmRunner` provided) are 100%
  unaffected — this guard is gated on `!llmRunner`.
- The 512-character threshold deliberately errs conservative: genuinely
  meaningful short content (e.g. a user's 3-line preference statement)
  may be skipped, but the user can still bump the threshold via a later
  follow-up that grows the conversation past the threshold.

Closes TencentCloud#176
@Maxwell-Code07

Copy link
Copy Markdown
Collaborator

Thank you for your interest and contributions! We will review your code and get back to you as soon as possible!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] l1-extractor 用 clean-workspace + 76 字节 stub context, LLM 返回空字符串

2 participants