Skip to content

Commit 073a6da

Browse files
author
jinxuesong
committed
Improve LLM diagnostics and timeout configuration
1 parent 2ebba8e commit 073a6da

File tree

8 files changed

+236
-25
lines changed

8 files changed

+236
-25
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ If you already have your own OpenAI-compatible services, just replace the releva
199199

200200
- `embedding`: change `apiKey` / `model` / `baseURL` / `dimensions`
201201
- `retrieval`: change `rerankProvider` / `rerankEndpoint` / `rerankModel` / `rerankApiKey`
202-
- `llm`: change `apiKey` / `model` / `baseURL`
202+
- `llm`: change `apiKey` / `model` / `baseURL` / `timeoutMs`
203203

204204
For example, to replace only the LLM:
205205

@@ -535,6 +535,7 @@ When `smartExtraction` is enabled (default: `true`), the plugin uses an LLM to i
535535
| `llm.apiKey` | string | *(falls back to `embedding.apiKey`)* | API key for the LLM provider |
536536
| `llm.model` | string | `openai/gpt-oss-120b` | LLM model name |
537537
| `llm.baseURL` | string | *(falls back to `embedding.baseURL`)* | LLM API endpoint |
538+
| `llm.timeoutMs` | number | `30000` | LLM request timeout in milliseconds |
538539
| `extractMinMessages` | number | `2` | Minimum messages before extraction triggers |
539540
| `extractMaxChars` | number | `8000` | Maximum characters sent to the LLM |
540541

@@ -551,7 +552,7 @@ Full config (separate LLM endpoint):
551552
{
552553
"embedding": { "apiKey": "${OPENAI_API_KEY}", "model": "text-embedding-3-small" },
553554
"smartExtraction": true,
554-
"llm": { "apiKey": "${OPENAI_API_KEY}", "model": "gpt-4o-mini", "baseURL": "https://api.openai.com/v1" },
555+
"llm": { "apiKey": "${OPENAI_API_KEY}", "model": "gpt-4o-mini", "baseURL": "https://api.openai.com/v1", "timeoutMs": 30000 },
555556
"extractMinMessages": 2,
556557
"extractMaxChars": 8000
557558
}

README_CN.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ openclaw logs --follow --plain | rg "memory-lancedb-pro"
199199

200200
- `embedding`:改 `apiKey` / `model` / `baseURL` / `dimensions`
201201
- `retrieval`:改 `rerankProvider` / `rerankEndpoint` / `rerankModel` / `rerankApiKey`
202-
- `llm`:改 `apiKey` / `model` / `baseURL`
202+
- `llm`:改 `apiKey` / `model` / `baseURL` / `timeoutMs`
203203

204204
例如只替换 LLM:
205205

@@ -535,6 +535,7 @@ OpenClaw 默认行为:
535535
| `llm.apiKey` | string | *(复用 `embedding.apiKey`* | LLM 提供商 API Key |
536536
| `llm.model` | string | `openai/gpt-oss-120b` | LLM 模型名称 |
537537
| `llm.baseURL` | string | *(复用 `embedding.baseURL`* | LLM API 端点 |
538+
| `llm.timeoutMs` | number | `30000` | LLM 请求超时(毫秒) |
538539
| `extractMinMessages` | number | `2` | 触发提取所需最少消息数 |
539540
| `extractMaxChars` | number | `8000` | 发送给 LLM 的最大字符数 |
540541

@@ -551,7 +552,7 @@ OpenClaw 默认行为:
551552
{
552553
"embedding": { "apiKey": "${OPENAI_API_KEY}", "model": "text-embedding-3-small" },
553554
"smartExtraction": true,
554-
"llm": { "apiKey": "${OPENAI_API_KEY}", "model": "gpt-4o-mini", "baseURL": "https://api.openai.com/v1" },
555+
"llm": { "apiKey": "${OPENAI_API_KEY}", "model": "gpt-4o-mini", "baseURL": "https://api.openai.com/v1", "timeoutMs": 30000 },
555556
"extractMinMessages": 2,
556557
"extractMaxChars": 8000
557558
}

index.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ interface PluginConfig {
131131
apiKey?: string;
132132
model?: string;
133133
baseURL?: string;
134+
timeoutMs?: number;
134135
};
135136
extractMinMessages?: number;
136137
extractMaxChars?: number;
@@ -211,6 +212,10 @@ function parsePositiveInt(value: unknown): number | undefined {
211212
return undefined;
212213
}
213214

215+
function resolveLlmTimeoutMs(config: PluginConfig): number {
216+
return parsePositiveInt(config.llm?.timeoutMs) ?? 30000;
217+
}
218+
214219
function resolveHookAgentId(
215220
explicitAgentId: string | undefined,
216221
sessionKey: string | undefined,
@@ -1654,12 +1659,13 @@ const memoryLanceDBProPlugin = {
16541659
? resolveEnvVars(config.llm.baseURL)
16551660
: config.embedding.baseURL;
16561661
const llmModel = config.llm?.model || "openai/gpt-oss-120b";
1662+
const llmTimeoutMs = resolveLlmTimeoutMs(config);
16571663

16581664
const llmClient = createLlmClient({
16591665
apiKey: llmApiKey,
16601666
model: llmModel,
16611667
baseURL: llmBaseURL,
1662-
timeoutMs: 30000,
1668+
timeoutMs: llmTimeoutMs,
16631669
log: (msg: string) => api.logger.debug(msg),
16641670
});
16651671

@@ -1681,7 +1687,13 @@ const memoryLanceDBProPlugin = {
16811687
noiseBank,
16821688
});
16831689

1684-
api.logger.info("memory-lancedb-pro: smart extraction enabled (LLM model: " + llmModel + ", noise bank: ON)");
1690+
api.logger.info(
1691+
"memory-lancedb-pro: smart extraction enabled (LLM model: "
1692+
+ llmModel
1693+
+ ", timeoutMs: "
1694+
+ llmTimeoutMs
1695+
+ ", noise bank: ON)",
1696+
);
16851697
} catch (err) {
16861698
api.logger.warn(`memory-lancedb-pro: smart extraction init failed, falling back to regex: ${String(err)}`);
16871699
}
@@ -1971,11 +1983,13 @@ const memoryLanceDBProPlugin = {
19711983
const llmBaseURL = config.llm?.baseURL
19721984
? resolveEnvVars(config.llm.baseURL)
19731985
: config.embedding.baseURL;
1986+
const llmTimeoutMs = resolveLlmTimeoutMs(config);
19741987
return createLlmClient({
19751988
apiKey: llmApiKey,
19761989
model: config.llm?.model || "openai/gpt-oss-120b",
19771990
baseURL: llmBaseURL,
1978-
timeoutMs: 30000,
1991+
timeoutMs: llmTimeoutMs,
1992+
log: (msg: string) => api.logger.debug(msg),
19791993
});
19801994
} catch { return undefined; }
19811995
})() : undefined,

openclaw.plugin.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,11 @@
436436
},
437437
"baseURL": {
438438
"type": "string"
439+
},
440+
"timeoutMs": {
441+
"type": "integer",
442+
"minimum": 1,
443+
"default": 30000
439444
}
440445
}
441446
},
@@ -812,6 +817,12 @@
812817
"placeholder": "https://api.groq.com/openai/v1",
813818
"help": "OpenAI-compatible base URL for LLM (defaults to embedding.baseURL if omitted)",
814819
"advanced": true
820+
},
821+
"llm.timeoutMs": {
822+
"label": "LLM Timeout (ms)",
823+
"placeholder": "30000",
824+
"help": "Request timeout for the smart-extraction / upgrade LLM in milliseconds",
825+
"advanced": true
815826
}
816827
}
817828
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
]
3737
},
3838
"scripts": {
39-
"test": "node test/embedder-error-hints.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node test/plugin-manifest-regression.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 test/workflow-fork-guards.test.mjs",
39+
"test": "node test/embedder-error-hints.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node test/plugin-manifest-regression.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-upgrader-diagnostics.test.mjs && node --test test/workflow-fork-guards.test.mjs",
4040
"test:openclaw-host": "node test/openclaw-host-functional.mjs",
4141
"version": "node scripts/sync-plugin-version.mjs openclaw.plugin.json package.json && git add openclaw.plugin.json"
4242
},

src/llm-client.ts

Lines changed: 128 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export interface LlmClientConfig {
1616
export interface LlmClient {
1717
/** Send a prompt and parse the JSON response. Returns null on failure. */
1818
completeJson<T>(prompt: string, label?: string): Promise<T | null>;
19+
/** Best-effort diagnostics for the most recent failure, if any. */
20+
getLastError(): string | null;
1921
}
2022

2123
/**
@@ -56,16 +58,108 @@ function previewText(value: string, maxLen = 200): string {
5658
return `${normalized.slice(0, maxLen - 3)}...`;
5759
}
5860

61+
function nextNonWhitespaceChar(text: string, start: number): string | undefined {
62+
for (let i = start; i < text.length; i++) {
63+
const ch = text[i];
64+
if (!/\s/.test(ch)) return ch;
65+
}
66+
return undefined;
67+
}
68+
69+
/**
70+
* Best-effort repair for common LLM JSON issues:
71+
* - unescaped quotes inside string values
72+
* - raw newlines / tabs inside strings
73+
* - trailing commas before } or ]
74+
*/
75+
function repairCommonJson(text: string): string {
76+
let result = "";
77+
let inString = false;
78+
let escaped = false;
79+
80+
for (let i = 0; i < text.length; i++) {
81+
const ch = text[i];
82+
83+
if (escaped) {
84+
result += ch;
85+
escaped = false;
86+
continue;
87+
}
88+
89+
if (inString) {
90+
if (ch === "\\") {
91+
result += ch;
92+
escaped = true;
93+
continue;
94+
}
95+
96+
if (ch === "\"") {
97+
const nextCh = nextNonWhitespaceChar(text, i + 1);
98+
// A string may legally end before object/array delimiters or a key colon.
99+
if (
100+
nextCh === undefined ||
101+
nextCh === "," ||
102+
nextCh === "}" ||
103+
nextCh === "]" ||
104+
nextCh === ":"
105+
) {
106+
result += ch;
107+
inString = false;
108+
} else {
109+
// Treat stray quotes inside a string as literal content.
110+
result += "\\\"";
111+
}
112+
continue;
113+
}
114+
115+
if (ch === "\n") {
116+
result += "\\n";
117+
continue;
118+
}
119+
if (ch === "\r") {
120+
result += "\\r";
121+
continue;
122+
}
123+
if (ch === "\t") {
124+
result += "\\t";
125+
continue;
126+
}
127+
128+
result += ch;
129+
continue;
130+
}
131+
132+
if (ch === "\"") {
133+
result += ch;
134+
inString = true;
135+
continue;
136+
}
137+
138+
if (ch === ",") {
139+
const nextCh = nextNonWhitespaceChar(text, i + 1);
140+
if (nextCh === "}" || nextCh === "]") {
141+
continue;
142+
}
143+
}
144+
145+
result += ch;
146+
}
147+
148+
return result;
149+
}
150+
59151
export function createLlmClient(config: LlmClientConfig): LlmClient {
60152
const client = new OpenAI({
61153
apiKey: config.apiKey,
62154
baseURL: config.baseURL,
63155
timeout: config.timeoutMs ?? 30000,
64156
});
65157
const log = config.log ?? (() => {});
158+
let lastError: string | null = null;
66159

67160
return {
68161
async completeJson<T>(prompt: string, label = "generic"): Promise<T | null> {
162+
lastError = null;
69163
try {
70164
const response = await client.chat.completions.create({
71165
model: config.model,
@@ -82,43 +176,61 @@ export function createLlmClient(config: LlmClientConfig): LlmClient {
82176

83177
const raw = response.choices?.[0]?.message?.content;
84178
if (!raw) {
85-
log(
86-
`memory-lancedb-pro: llm-client [${label}] empty response content from model ${config.model}`,
87-
);
179+
lastError =
180+
`memory-lancedb-pro: llm-client [${label}] empty response content from model ${config.model}`;
181+
log(lastError);
88182
return null;
89183
}
90184
if (typeof raw !== "string") {
91-
log(
92-
`memory-lancedb-pro: llm-client [${label}] non-string response content type=${Array.isArray(raw) ? "array" : typeof raw} from model ${config.model}`,
93-
);
185+
lastError =
186+
`memory-lancedb-pro: llm-client [${label}] non-string response content type=${Array.isArray(raw) ? "array" : typeof raw} from model ${config.model}`;
187+
log(lastError);
94188
return null;
95189
}
96190

97191
const jsonStr = extractJsonFromResponse(raw);
98192
if (!jsonStr) {
99-
log(
100-
`memory-lancedb-pro: llm-client [${label}] no JSON object found (chars=${raw.length}, preview=${JSON.stringify(previewText(raw))})`,
101-
);
193+
lastError =
194+
`memory-lancedb-pro: llm-client [${label}] no JSON object found (chars=${raw.length}, preview=${JSON.stringify(previewText(raw))})`;
195+
log(lastError);
102196
return null;
103197
}
104198

105199
try {
106200
return JSON.parse(jsonStr) as T;
107201
} catch (err) {
108-
log(
109-
`memory-lancedb-pro: llm-client [${label}] JSON.parse failed: ${err instanceof Error ? err.message : String(err)} (jsonChars=${jsonStr.length}, jsonPreview=${JSON.stringify(previewText(jsonStr))})`,
110-
);
202+
const repairedJsonStr = repairCommonJson(jsonStr);
203+
if (repairedJsonStr !== jsonStr) {
204+
try {
205+
const repaired = JSON.parse(repairedJsonStr) as T;
206+
log(
207+
`memory-lancedb-pro: llm-client [${label}] recovered malformed JSON via heuristic repair (jsonChars=${jsonStr.length})`,
208+
);
209+
return repaired;
210+
} catch (repairErr) {
211+
lastError =
212+
`memory-lancedb-pro: llm-client [${label}] JSON.parse failed: ${err instanceof Error ? err.message : String(err)}; repair failed: ${repairErr instanceof Error ? repairErr.message : String(repairErr)} (jsonChars=${jsonStr.length}, jsonPreview=${JSON.stringify(previewText(jsonStr))})`;
213+
log(lastError);
214+
return null;
215+
}
216+
}
217+
lastError =
218+
`memory-lancedb-pro: llm-client [${label}] JSON.parse failed: ${err instanceof Error ? err.message : String(err)} (jsonChars=${jsonStr.length}, jsonPreview=${JSON.stringify(previewText(jsonStr))})`;
219+
log(lastError);
111220
return null;
112221
}
113222
} catch (err) {
114223
// Graceful degradation — return null so caller can fall back
115-
log(
116-
`memory-lancedb-pro: llm-client [${label}] request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`,
117-
);
224+
lastError =
225+
`memory-lancedb-pro: llm-client [${label}] request failed for model ${config.model}: ${err instanceof Error ? err.message : String(err)}`;
226+
log(lastError);
118227
return null;
119228
}
120229
},
230+
getLastError(): string | null {
231+
return lastError;
232+
},
121233
};
122234
}
123235

124-
export { extractJsonFromResponse };
236+
export { extractJsonFromResponse, repairCommonJson };

src/memory-upgrader.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,8 @@ export class MemoryUpgrader {
315315
}>(prompt);
316316

317317
if (!llmResult) {
318-
throw new Error("LLM returned null");
318+
const detail = this.llm.getLastError();
319+
throw new Error(detail || "LLM returned null");
319320
}
320321

321322
enriched = {

0 commit comments

Comments
 (0)