memory: distinguish a failed extraction from an empty one

Probing the extraction path with a real model call showed it returning a
zero-candidate outcome — indistinguishable from "the conversation had
nothing worth keeping". It was actually an auth failure delivered as a JSON
error body rather than a thrown error, which coerceText turned into an empty
string, which parseCandidates turned into an empty array.

extract() now reports `error` and a `rawText` preview instead of collapsing
every failure mode into `{candidates: []}`. An auth error arriving as a
return value rather than a throw is the common case, not the exotic one.

memory_inspect prints the error and the raw response, so this class of
failure now shows up instead of looking like a healthy extractor that
rarely fires.

Verified: thrown error, error object, empty string, and null each produce a
specific message; valid JSON and fenced JSON still parse.
This commit is contained in:
2026-10-09 10:31:06 +08:00
parent 92c612b94c
commit 8dc02b6b35
2 changed files with 54 additions and 7 deletions
+43 -5
View File
@@ -218,6 +218,15 @@ export function redactSecrets(text: string): string {
export interface ExtractionOutcome {
candidates: Candidate[]
rejected: { candidate: Candidate; reason: string }[]
/**
* Set when the round-trip itself failed. Absent means the model answered.
* Without this, a broken call and a conversation with nothing worth keeping
* both come back as zero candidates — which is how a permanently broken
* extractor can look like a healthy one that simply rarely fires.
*/
error?: string
/** Raw model text, truncated, for diagnosing a surprising empty result. */
rawText?: string
}
/**
@@ -302,7 +311,13 @@ export interface RunExtractionInput {
signal?: AbortSignal
}
/** One model round-trip. Returns screened candidates and why things were dropped. */
/**
* One model round-trip. Returns screened candidates and why things were dropped.
*
* A transport failure is reported in `error` rather than collapsed into an
* empty result: the caller's only other signal would be "no candidates", which
* is also what a correct answer looks like when nothing was worth keeping.
*/
export async function extract(input: RunExtractionInput): Promise<ExtractionOutcome> {
const prompt = `${SYSTEM}\n\n---\n\n${input.transcript}`
let result: unknown
@@ -314,11 +329,34 @@ export async function extract(input: RunExtractionInput): Promise<ExtractionOutc
prompt,
...(input.signal ? { signal: input.signal } : {}),
})
} catch {
// An extraction failure must never disturb the user's session.
return { candidates: [], rejected: [] }
} catch (error) {
// An extraction failure must never disturb the user's session, but it must
// not be indistinguishable from success either.
return {
candidates: [],
rejected: [],
error: `模型调用失败:${(error as Error).message.slice(0, 160)}`,
}
}
const text = coerceText(result)
// An empty string here means the call "succeeded" but returned nothing usable.
// An auth or provider error can arrive as a JSON error body rather than a
// thrown error, so surface what actually came back instead of reporting it
// as "nothing worth remembering".
if (!text.trim()) {
const preview =
result && typeof result === "object"
? JSON.stringify(result).slice(0, 160)
: String(result ?? "").slice(0, 160)
return {
candidates: [],
rejected: [],
error: `模型返回空内容(响应:${preview || "空"})`,
}
}
const candidates = parseCandidates(text)
return screenCandidates(candidates, input.existing)
const screened = screenCandidates(candidates, input.existing)
return { ...screened, rawText: text.slice(0, 400) }
}