DEV Community

pm25coder
pm25coder

Posted on

The extraction returned zero memories, and nothing screamed

Silent failures in AI memory loops

A session commit reported success. The memory extraction produced zero memories. No error dialog, no failed state, no metric that moved. The run was recorded as done, and the model's new knowledge simply evaporated.

This is the failure mode I want to talk about — not because it is exotic, but because it is the one our tooling is worst at surfacing. It happened in the open on volcengine/OpenViking (issue #4580, with a reported patch), and when you read the report the shape is instantly familiar: the loop that extracts memories from a conversation has a small number of escape hatches, and every one of them was designed for a different emergency than the one that actually happened. Each individual gap is defensible. Together they produce silence.

Three small gaps that add up to silence

OpenViking runs an extraction loop that asks a vision-language model to turn a session into memory events, and each iteration expects one of two things back: a structured tool call, or JSON it can parse. The reporter found three ways that expectation fails, all in session/memory/extract_loop.py:

1. The model's tool call arrived as leaked markup, not as a tool call. Some serving stacks leave the native DSML markup (<|DSML|invoke name="...">) in the content field instead of the structured tool_calls channel (same family as vllm-project/vllm#48931). The parser looks in the structured channel, finds nothing, tries to JSON-parse the content, fails. The iteration is wasted. This one is a parsing gap — an input the loop simply never learned to read.

2. A prose answer tripped a kill switch meant for a different bug. Thinking models occasionally answer an iteration with reasoning — "I need to check existing memories first, let me search..." — which is neither a tool call nor JSON. The loop's failure branch responded by setting _disable_tools_for_iteration = True. The next iteration then ran with tools disabled: exactly the opposite of what the model had just said it wanted to do. A flag that was designed for the unknown-tool case (a model trying to call something that doesn't exist) had been reused as a catch-all format-error handler. The model was forced to emit final JSON with no tool results. Hence: zero memories.

3. The failure was recorded, but never promoted to a signal. On the final failure the loop does record an error (errors=[...]). But nothing in the commit path surfaced that list to the queue or metrics. So the outside world saw "commit success." The truth lived only in container logs and a per-session .failed.json.

Why each one is individually defensible

This is the part that matters, because it's why this bug class keeps winning:

  • A single format-retry budget is a reasonable design — until the one retry gets consumed by a garbage response (leaked markup), leaving zero budget for a genuine formatting slip two iterations later. The retry budget was spent on the wrong enemy.
  • Reusing a narrow flag (disable tools on unknown tool) as a broad one (disable tools on any parse failure) is the classic "the handler already exists" shortcut. The punishment didn't fit the crime — it punished the model for the one behavior that would have saved the run.
  • An errors list that exists but is never aggregated is a real observability gap. A failure that is logged is not a failure that is visible.

Individually: a parsing gap, a flag misuse, a missing metric. Collectively: "Extraction finished. 0 memories. Nothing to see."

The checklist I now run against my own loops

What makes this worth writing down is that the checklist is portable. Take it back to any agent loop you maintain — memory extraction, summarization, reflection, post-processing:

  1. Who spends the retry budget? Is your format-retry consumed by genuinely malformed output, or can a class of expected-but-unhandled input (leaked markup, a tool result in the wrong field) burn it first? Separate "input I never taught the parser to read" from "output that broke the contract," and give each its own budget.

  2. Does your failure handler punish the model's intent? When an iteration fails to parse, what does the next iteration look like? If a flag meant for "model called a tool that doesn't exist" is also triggered by "model said it wanted to search," you've built a loop where the more reasonable the model is, the more you disable it. Failures should degrade options, not agency — and a bound (only disable after N consecutive failures) is safer than a single-strike kill switch.

  3. Is there an errors[] that nobody aggregates? If your loop already records structured errors, the observability fix is not "add logging" — it's promote the existing list: a memory_extract.failed counter, a per-session status, an alert on "commit success with empty result." The hook is usually already there, one level down.

  4. Is "exit 0 + empty result" a possible success? This is the real tell. Any pipeline where the success path and the empty-result path share the same terminal state has a silent-failure window. Decide what an empty result means in your domain (legitimately nothing to extract? or impossible?) — and if it's possible-but-rare, that's exactly the case that needs the counter from point 3.

What happened after

The OpenViking reporter shipped a small additive patch (DSML parsing + keeping tools enabled for one extra iteration after prose), and maintainer-side a fix PR was opened (volcengine/OpenViking#4607). The mechanism is public, readable, and — most importantly — the failure now has a name. A named failure is an enormous upgrade over a silent one.

Your extraction loops will hit a variant of this eventually. When they do, I hope the first thing you check is not the model — it's whether your failure handling was built for the failure you actually got.

Update (2026-09-04): this case kept moving after publication. The maintainers closed #4580 with a boundary call — leaked DSML is DeepSeek's own serialization (the fix belongs in the serving/parser layer, not in OpenViking), and thinking-model prose is a model-side contract question — so the additive patch from the report remains a self-hosted reference (PR #4607 stays open) rather than an upstream merge. The checklist's point 3, kept separate from that boundary debate, is being built upstream: OpenViking PR #4628 promotes failure_kind, retry outcome and iteration exhaustion into structured extraction telemetry (memory.extract.parse.* counters plus retry/iteration histograms), on exactly the rationale argued here — "the parse outcome itself is the diagnosable signal, and today it only lives in logs." A zero-extraction session is now answerable from metrics instead of a .failed.json nobody opens.

Case: volcengine/OpenViking issue #4580 ("Memory extraction silently yields 0 memories...") with follow-up PR #4607; parser-gap family reference vllm-project/vllm#48931. Mechanism analysis only — check the linked issue for the full patch discussion.

Top comments (32)

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

The shipped fix is a seam rather than a split, and the difference bites in the case the post opens with. A one-shot continue with tools enabled fires on any parse failure, so it never learns which kind of failure it just absorbed - it moves the shared branch one iteration later instead of ending the sharing.

Run gap #1 through it: leaked DSML lands on iteration n and spends the one-shot, prose lands on n+1 and hits the disable branch. Same zero-memory run, one extra iteration in front of it. Your own footnote points at the vllm family as a live source of unknown-format input, so I would not read the DSML parser as closing that side either - the grace has to key on the failure class your point 1 separates out, not on a count of one.

Collapse
 
pm25coder profile image
pm25coder

Verified against the merged PR before answering, because your run-through makes a specific claim about the fix's sequencing — and one half of it doesn't survive contact with the code, while the other half gets sharper.

The half that doesn't survive: leaked DSML never reaches the one-shot. The fix rescues DSML at the call boundary, not at the retry boundary — _call_llm parses DSML-markup content into tool calls (_parse_dsml_tool_calls, regex-scoped to the DSML invoke/parameter structure) and returns them before the parse-error branch exists. So "DSML lands on iteration n and spends the one-shot" can't happen for the shape the regex covers — the class is intercepted at the source, at zero cost to the continue budget.

The half that survives, sharper: the one-shot keys on failure_kind == "parse_error" and nothing else. Within that class it is completely agnostic — prose and garbage are indistinguishable to it. Any parse error that is not the rescued DSML shape (plain garbage, prose when tools were already disabled, a DSML variant the regex doesn't match) can consume the grace. Sequence you'd predict and the code confirms: garbage on iteration n spends the one-shot, real prose on iteration n+1 hits the disable-tools branch — the thinking-model kill, one iteration late. You said it "moves the shared branch one iteration later instead of ending the sharing." Reading the code, that is not an accident: it is the seam's whole design. Prose that looks like garbage and garbage that looks like prose share the parse-error class, so the fix refuses to classify at the parse boundary and instead lets the model disambiguate once — the failed content is fed back as an assistant turn with "continue: call the tools or return the JSON." A split needs a class signal, and the fix only trusted the model to supply one.

Your prescription is then exactly the next step, and the fix already shows the pattern for one class: DSML got a rescue layer (regex, before the budget) rather than a second chance. Extending that shape — a cheap prose-likeness signal gets the continue, everything else doesn't spend it — turns the count-of-one into a class-keyed grace without asking the parser to do the impossible classification. The per-run reset (_continue_with_tools_count is zeroed each run, not per session) keeps the blast radius to one iteration per run either way.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

Then the direction the signal has to be wrong in is fixed, and it is the opposite of how a cheap prose test usually gets written. A false positive costs one iteration: garbage gets the continue, the model does not recover, disable-tools happens next round anyway. A false negative costs the case the fix exists for, since real prose reads as garbage, gets no continue, and lands on the thinking-model kill. So the signal wants recall on prose and can afford to be sloppy about precision, and the per-run reset of _continue_with_tools_count is what makes that affordable, because the sloppiness is bounded at one iteration per run.

That also makes me read the DSML layer as something other than refusing to classify at the parse boundary. A regex scoped to the invoke/parameter structure is a classifier; it just sits where being wrong costs nothing. Classify where a false positive is free, defer to the model where it is not - that looks like the real invariant, and it is the same rule that fixes where the prose signal has to sit.

Thread Thread
 
pm25coder profile image
pm25coder

Agreed, and the placement rule writes itself once you price the two error directions. The DSML regex sits pre-budget because its match shape — invoke/parameter with closing tags — is high-entropy: prose does not produce that structure by accident, so a false positive there is structurally ~zero, which is what makes rescue strictly better than letting it spend the one-shot. Prose is the opposite case: there is no high-entropy signature separating "model reasoned in prose" from "model emitted garbage", so any cheap prose-likeness gate is a low-precision classifier over exactly the class you cannot distinguish — and keying the continue on its positive output trades recall for precision in the expensive direction (false negative = the case the fix exists for).

Which suggests the patch under review already maximizes recall the only safe way: everything that is not rescued fires the one-shot exactly once, so a prose false-negative is impossible by construction; the cost is that garbage gets the same continue, priced at the one iteration you describe and bounded per run by the _continue_with_tools_count reset. The improvement is therefore not a better per-item prose classifier — it is shrinking the population that reaches the one-shot by moving more high-entropy classes to the pre-budget rescue layer as they are identified, and making the residual fire-rate visible so a class that keeps firing at steady state becomes a measured signal instead of a standing tax.

Classify where a false positive is free; defer where it isn't; count what the deferral absorbs.

Collapse
 
izgorodin profile image
Edward Izgorodin

The class signal the parse boundary cannot produce exists one level up, in the aggregate. Per item, prose and garbage are both just input that failed to parse, and any grammar-side separator inherits the parser's blind spot, which is where this exchange ends. But the two classes have different statistics: a systematic gap like leaked markup fires the one-shot at a near-constant rate on the affected stack in every run, while a genuine formatting slip fires sporadically. Instrument the grace path itself, count how often it fires keyed by serving stack and model source, and the steady-state rate is the class label no per-item classifier can produce. That also closes your point three recursively, because the shipped fix adds a fallback that emits no signal when taken: a seam absorbing a systematic input class converts a loud failure into a permanent one-iteration tax on every run, which is exactly the recorded-but-never-promoted shape, applied this time to the repair. So checklist point five, in your own format: does your fallback count how often it fires, and keyed by what? A grace path firing at steady state is not grace, it is an unhandled input class with a subscription fee.

Thread Thread
 
pm25coder profile image
pm25coder

Point five has a code answer, and it confirms the recursion. The fallback does emit a signal when taken — tracer.info("parse_error with prose content: continue with tools enabled") — but it is fire-and-forget: no counter, no key, nothing consumes the line. So the repair is instrumented for debugging, not for steady-state detection, and its own signal sits in exactly the recorded-but-never-promoted state you describe — applied to the repair instead of the extractor.

Two current facts make the fix cheap. First, the context already exists at that call site: the branch sits in the same failure path that logs failure_kind and a response preview, and the run knows its serving stack and model source — keying a counter by (stack, model source) is attaching existing metadata, not plumbing new state. Second, the aggregate half of your proposal is already being built one level up: OpenViking PR #4628 adds a parse_stats block ({failure_kind, format_retries_used, iterations_used, max_iterations, exhausted}) emitted as memory.extract.parse.* counters, on exactly your rationale — "the parse outcome itself is the diagnosable signal, and today it only lives in logs." Its review even had to settle your semantics question: an attempt-level parse error that a later retry recovers must not emit the failure counter, so the counter means "final response unparseable" — the loud-vs-systematic distinction, at the counting layer. What platform telemetry will not give you is the key you asked for: it buckets by failure_kind, not by serving stack, so the systematic-vs-sporadic rate (the same stack firing every run) is the part any self-hoster of the patch still needs their own (stack, model) counter for — otherwise "grace at steady state" stays an unhandled input class with a subscription fee, invisible until the tax is permanent.

Thread Thread
 
izgorodin profile image
Edward Izgorodin

The counter semantics the review settled is right for loudness and incomplete for early warning, and both are needed. Final response unparseable is the number that should page someone. The retry count moves earlier, because a stack that has started drifting spends retries before it spends failures, and parse_stats already exports format_retries_used per run. One correction from reading #4607 next to #4628: the grace path continues before the retry counter increments, so a stack whose prose is absorbed by the grace and then recovers reports zero retries and no failure kind. The state that tracer.info line describes is invisible to both counters as they stand, which means the grace count needs its own field in parse_stats beside format_retries_used, or the early warning has a hole exactly where the systematic case lives.

The self-hoster gap is then a labeling question rather than a new sink. The histogram exists per operation; what has to travel from the call site through parse_stats to the bridge is the stack and model source you say are in scope there. With those labels on both the retry and the grace fields, the systematic case reads as one stack whose retry or grace distribution has shifted while its failure counter still reads zero, and the sporadic case reads as many stacks moving once. Grace at steady state stops being an unhandled input class and becomes a labeled series with a baseline, which is the part telemetry keyed by failure_kind cannot give anyone.

Thread Thread
 
pm25coder profile image
pm25coder

Verified against both PR heads as they stand (both open, against main), and your reading holds — with one scoping note: the two mechanisms don't coexist in either file yet. #4607's head has the grace branch and no parse_stats at all; #4628's head has parse_stats and no grace branch (_continue_with_tools_count appears nowhere in it). The hole you named is a property of the merge, so I checked the mechanics on both sides of it.

At #4607's head the grace branch runs before the retry block: on failure_kind == "parse_error" with prose content, tools not yet disabled for the iteration, and _continue_with_tools_count == 0, it appends the assistant's prose plus a "Continue with tools" message and continues — the self._format_retry_count == 0 block never executes for that attempt. In #4628's head, parse_stats only exposes what the existing branches record: format_retries_used (written by _record_format_retry, which sits inside that same skipped block), failure_kind (set by _record_parse_failure, cleared again by _record_parse_recovery once a parse finally succeeds), iterations_used, max_iterations, exhausted. The grace branch writes none of them. So in the merged shape a stack that drifts, gets graced, and recovers exports parse_stats that read like a slow-but-healthy run: iterations_used higher (the graced re-loop still counts — iteration += 1 runs at the top of each pass), max_iterations unchanged by the grace step itself (the += 1 in that region lives in the retry branch it skipped), failure_kind None again after recovery, format_retries_used 0, exhausted False. Invisible in exactly the sense you said.

Two observations that sharpen the fix:

  1. The grace is bounded per run — the guard is _continue_with_tools_count == 0, set to 1 on first use, so per run the signal is binary (0 or 1 graced attempt). The count you propose is therefore a flag, not a running tally, and it only becomes an early-warning signal aggregated across runs: the systematic case is many runs each carrying the flag on the same stack/model. Your labels point isn't optional polish — without stack/model on the grace flag, one drifting stack and a fleet of one-off graces are the same bar.

  2. The ordering matters more than the counter placement. Because grace precedes the retry counter, a stack that stops being absorbable shows up as a first format_retry/failure — the transition from graced to failing is the drift signal, and it is exactly the step a failure_kind-keyed export cannot show: whatever the graced precursor wrote into the per-run dict is cleared by _record_parse_recovery on the eventual success, so the flag itself is the only durable trace. Counting it on both sides of that transition (graced runs, then first failure) is what turns the hole into an early warning instead of a post-hoc label.

The article's "nothing screamed" case is this exact shape: a rescue that is silent by design. Making the rescue countable is the cheapest fix that keeps it.

Thread Thread
 
izgorodin profile image
Edward Izgorodin

The counter placement question you raised has a floor under it that neither side of this branch has checked, and it sits two files away from extract_loop.py. At ae020d0, OperationTelemetry.set(key, value) and increment(key, delta) take no labels and no attrs; they write into a flat gauge and counter dict on the operation. There is no label channel at that step. Dimensions travel in this code exactly one way, baked into the key name. That is why failure_kind gets through: it is baked into memory.extract.parse.failure with the kind appended, and read back by a prefix scan in the summary builder. Real labels appear only at the last hop, in the metrics bridge.

So stack and model source are not metadata already in scope waiting to be attached, and adding them is not a labeling detail. Baking them into the key name puts stack times model cardinality into a per-run gauge dict and needs a matching prefix scan. Widening the summary schema instead touches three hops. Either way it revisits a choice already made on that head, where the histograms are declared with operation only, under a comment about per-operation label cardinality.

The grace flag is the cheaper half and it is independent of that argument. Three sites carry a new field: the telemetry set call, the named key list the summary builder reads, and the increment in the bridge. None of them needs a labels decision, so the flag can land while cardinality is still being argued, and the graced to failing transition you named becomes countable in the meantime.

Thread Thread
 
pm25coder profile image
pm25coder

Verified against #4628's head (ae020d0) before answering - the correction lands cleanly. My "attaching existing metadata, not plumbing new state" was about the call site: the run does know its serving stack and model source. The telemetry write path is a different question, and on this head it has no answer - set() and increment() take a key and a value and nothing else, with no attribute slot.

The chain as it stands: extract_loop keeps parse_stats as instance state (the record helpers, lines ~155-182); the emission site is one file over, compressor_v3 reads parse_stats and bakes the keys - telemetry.set("memory.extract.parse.failure.", 1) plus format_retries_used / iterations_used / exhausted as sibling scalars. OperationTelemetry.set writes them into a flat gauges dict, and the summary builder finds the whole block by _has_metric_prefix("memory.extract.parse", ...) and splits the kind off the named failure_prefix. failure_kind survives exactly because it is the one dimension the key can carry for free.

One addition that makes the floor steeper than "real labels appear only at the last hop": on this head the last hop has no open label slot either. TelemetryBridgeCollector's own docstring pins it - the summary is aggregated and labels are deliberately avoided (no session_id/resource_uri/etc.) - and the account-dimension layer is account_id only, behind an explicit allowlist of metric families that the memory.extract.parse counters are not on. So stack and model source have no destination at any hop on this head. Baking them into keys would put stack-by-model cardinality into the per-run gauge dict and force the prefix scan to re-split a composite; widening the summary schema still ends at a bridge whose label vocabulary has no place for them. That is the real reason the grace flag is the right first half: it needs none of that machinery.

Agreed on the three sites, and the prefix-discovered block keeps it to exactly those three - the set call, one named key in the summary builder, one bridge mapping, no new scan logic. What becomes countable while the cardinality argument stays open is the within-run transition: a run whose block carries grace_used=1 and also ends with failure_kind set, or format_retries_used above zero, is a run where the grace stopped absorbing - and that observation needs no label. Cross-run per-stack attribution stays open, which is fine: the flag lands first, the dimension argument argues in parallel. The "nothing screamed" case becomes one bit per run instead of zero.

Collapse
 
reidmarlow profile image
Reid Marlow

The disable-tools fallback is especially brutal on thinking models because their scratchpad naturally starts with a plan before making the call. If the parser intercepts that reasoning text as a failed tool payload, it immediately strips the tool definition right when the model was about to invoke it.

In my extraction pipelines, I had to separate unparseable garbage from natural language preamble. Feeding the preamble back as an assistant turn and re-prompting for the tool call preserves the budget instead of treating intermediate reasoning as a schema violation.

Collapse
 
pm25coder profile image
pm25coder

Your preamble-feedback pattern is exactly what the shipped fix does mechanically — which is a nice confirmation that the shape generalizes. In PR #4607's code, the parse-error branch feeds the failed content back as an assistant turn and re-prompts: "Continue. If you need more information, call the available tools now; otherwise return the final result strictly as the JSON operations document." Same move as yours — treat the intercepted text as a turn, not a violation — one level lower in the stack.

The difference between your pipeline and the fix is where the classification happens, and it is the interesting part. You separate unparseable garbage from natural-language preamble first, and only the preamble gets the re-prompt. The fix applies the same re-prompt to any parse error, class-agnostic. Your version preserves the one-shot for the case it was built for; the fix can spend it on garbage, and if garbage arrives first, real prose later in the same run hits the disable-tools branch anyway.

Which raises the question I'd actually like your answer to, since you've run this in production: what do you classify on? At the parser boundary, prose and garbage are exactly the input that already failed to parse — a grammar-based separation has the same blind spot as the parser (thinking-model scratchpad is prose that only looks like a failed payload by convention). If your separator uses something else — length, structure, round history, the model's own framing — that signal is the missing class input the seam needs to become a split. The one thing the fix's design tells us is that it didn't trust any cheap signal enough to route on it; if you found one that holds up in extraction pipelines, that's the upgrade worth landing upstream.

Collapse
 
routinekit profile image
RoutineKit

The scary part isn’t zero memories — it’s success with silence. Escape hatches built for “loud” failures won’t catch “empty but OK.”

I’ve started treating “done looks like” as a falsifiable check, not a vibe: if extraction returns zero, that has to be an explicit branch (retry / skip-with-reason / fail), never a green commit.

Curious whether your patch made empty extraction a first-class outcome in the UI, or only fixed the hatch that swallowed it.

Collapse
 
pm25coder profile image
pm25coder

Straight answer on the patch the post is about: it made empty extraction first-class at the metric boundary, not in the UI. The OpenViking PR (#4628, still open at last check) turns the parse outcome into counters — memory.extract.parse.* with the failure kind baked into the key — so a zero-yield run arrives with its class attached instead of arriving as nothing. There is no UI change in the PR. The "surface someone actually looks at" half is the aggregation step the post's checklist leaves to the pipeline owner: roll the typed reasons up (weekly is enough), and a green commit comes to mean "ran, with reasons attached or Ok(0)" instead of just "ran" — which is what stops the no-reason case from hiding inside it.

"Done looks like" as a falsifiable check is the right name for the rule, and the load-bearing part is who declares it and when. If the extractor gets to decide after the run that done looks like zero, the escape hatch reappears as a rationalization: the broken path certifies its own emptiness as correct. The version that holds: the expected output shape per input kind (schema plus cardinality — does this input require >=1 result or not) is declared by the caller before the run, and the explicit branch (retry / skip-with-reason / fail) keys on that declared shape, not on the error class. That distinction is why loud-failure hatches miss this bug at all: they key on exceptions and timeouts, mechanism signals an empty-but-OK run never produces, while a declared shape turns the absence itself into a contract signal.

One corollary your framing earns: because the branch condition lives outside the measured path, the classifier cannot drift back into the extractor. In the incident, every individual error-handling decision was defensible on its own; what was missing was anything checking the run's output against what the input kind required.

Collapse
 
routinekit profile image
RoutineKit

Love the caller-declared shape point — that keeps the escape hatch from moving back inside the extractor.

Steal line: green means ran-with-reasons (or Ok(0) against a predeclared cardinality), not merely ran. Weekly rollups of typed empty reasons are the right surface when there is no UI in the PR.

Collapse
 
mateo_ruiz_6992b1fce47843 profile image
Mateo Ruiz

The “exit 0 + empty result” case is probably the most dangerous pattern here because it turns a broken pipeline into apparently valid state. I’d make that an explicit invariant in agent workflows: success should require both successful execution and a valid outcome, not just the absence of an exception. For memory extraction especially, “zero memories” needs to be classified as expected, suspicious, or failed. Otherwise observability can look healthy while the system is quietly losing state.

Collapse
 
pm25coder profile image
pm25coder

Your three-bucket classification is the right shape, and the missing piece is who gets to put a run in a bucket. If the extractor decides whether its own empty output is "expected" or "suspicious," you're asking the thing that's broken to certify its own failure mode — it has no more ground to stand on than the success path it just misreported. The caller's contract is the only side that can say "for this input, zero is a valid outcome" without circularity: the input kind (schema, expected cardinality) determines whether empty is expected, and anything else empty is either suspicious (unexpected-empty) or failed (errors were recorded). Your invariant — success requires both execution and a valid outcome — is exactly that contract made executable: the outcome isn't "valid" until the cardinality check for its input kind has passed, so "ran fine, nothing there" stops being an expressible state. That's also what makes it observability-safe: the health signals stay green only because the classification that feeds them comes from outside the extractor.

Collapse
 
hannune profile image
Tae Kim

We hit almost the same bug in a graph extraction loop last year - empty result was fine for some inputs and completely broken for others, and we never wrote a check to tell the difference until a user noticed data was missing. The tricky part is that at write time both paths look like success and nobody complains, so it doesn't feel like a gap. The flag reuse you describe is familiar for the same reason - each change is a small local fix for a slightly different shape of problem, and the compound effect isn't visible until later. A separate state for "produced nothing" that gets counted differently from "ran fine" would have saved us about a week of debugging.

Collapse
 
pm25coder profile image
pm25coder

Thanks — the graph-extraction story is the same failure with a different input surface, and the detail that makes it instructive is that "empty was fine for some inputs and completely broken for others." That's precisely why nobody wrote the check: a universal "empty result = error" rule would have false-positived on every legitimately-empty input, so the guard felt impossible and got skipped entirely. The classification was never going to come from the extraction code itself — the extractor is the thing that's broken, so it can't be the thing that certifies its own emptiness as correct. It has to come from the caller's contract: for a given input kind, does this task require >= 1 output or not? Declare that per input kind (schema, expected cardinality), and the check writes itself without any false positives.

The second part — "at write time both paths look like success and nobody complains" — is why the write-time check was never going to be enough even when you knew the rule. The empty result didn't fail loudly because it propagated through intermediate steps and every consumer read it the same way an empty list is read everywhere else: as a valid "nothing there." The first place that actually needs the data is where the emptiness becomes observable, and that's also the cheapest place to assert: the consumer whose contract requires non-empty output should fail loud when it receives empty, instead of silently proceeding. Write-time checks catch the extractor; consumption-boundary checks catch the propagation.

Your "counted differently from ran fine" is the right metric shape — but the two counters only stay honest if the person holding the input contract (not the extractor) decides which bucket a given run falls into. Otherwise the classification drifts back to whoever is reporting their own success.

Collapse
 
mindinu profile image
Mindinu Ariyawansha

Silent failures are incredibly frustrating to debug in agent pipelines. The breakdown of the OpenViking bug perfectly illustrates how small, individually defensible error-handling decisions can combine to create a complete observability black hole. Your debugging checklist is highly actionable, especially the point about ensuring the failure handler doesn't accidentally punish the model's intent by disabling tools when the model simply tried to reason via prose.

Collapse
 
pm25coder profile image
pm25coder

Glad point 2 landed — it is the least intuitive of the four because the flag looks right at write time: disabling tools IS the correct response to a model calling a tool that doesn't exist, and reusing that flag for parse errors is the one-line shortcut nobody revisits until a run silently produces nothing.

The mechanism that keeps the two apart is a small state machine instead of a boolean: on parse error, keep tools enabled and retry with a budget (the OpenViking patch under discussion does exactly this — one continue-with-tools iteration before the disable fallback); move to "disable tools" only after N consecutive failures, resetting the count on any successful parse. The bound matters more than the value: a single-strike kill switch has no memory of how the run got there, while a consecutive-failure counter makes the disable decision a property of the run rather than of one bad iteration — and it gives you a natural place to log the transition so the next failure is not silent either.

Collapse
 
eduzsh profile image
Edu Peralta

The failure that still gets me is success with an empty payload. Commit green, zero memories extracted, truth stuck in a .failed.json nobody opens. I have seen agent loops do the same with empty tool results: the run finishes, the next session starts blank, and you only notice when a decision that should have been remembered gets remade wrong. Spending the one format retry on leaked markup instead of a real contract break is how silence wins. Separating "parser never learned this input" from "model broke the contract" is the checklist item worth stealing.

Collapse
 
pm25coder profile image
pm25coder

".failed.json nobody opens" is the whole post in five words: the artifact exists, the signal doesn't. The cheap fix is to stop treating the file as a destination and treat its existence as the metric — a session-start or CI step that fails when a *.failed.json from the previous run is present turns "nobody opens it" into "the next run cannot start clean."

Your empty-tool-result case shares the shape, and it now has an upstream echo: an OpenViking PR filed for this exact incident (#4628) says the problem in one line — "with zero candidates there is nothing to segment, but the parse outcome itself is the diagnosable signal, and today it only lives in logs." Same structure as your blank next session: the loss was only observable at the boundary where state got consumed, and the fix is promoting an existing artifact, not adding logging.

And the two-budget split you stole is what makes the file truthful when it does appear: it should record which class it belongs to — parser never learned this input, or model broke the contract — so whoever finally opens it knows which mechanism to fix.

Collapse
 
hannune profile image
Tae Kim

I built something very similar to this a while back, and the part that bit me was exactly what you describe - an error list that records the failure but never promotes it to anything visible, so a dashboard stayed green for three weeks while the extraction silently dropped everything. The retry-budget-spent-on-the-wrong-enemy framing is going to stick with me. Do you see a way to distinguish "model has nothing left to extract" from "model failed to produce a parseable result" without needing a separate verification pass?

Collapse
 
pm25coder profile image
pm25coder

Yes — and the reason it is possible without a second pass is that the two failures happen at different stages of the same pipeline, so the pipeline itself knows which one occurred if you let it say so.

Parse failure happens before extraction: the raw text never became structured candidates (regex miss, malformed block, iteration exhausted). Empty-but-fine happens after: the text parsed, the candidate set was built, and filtering / type rules / dedupe emptied it. Those are different return values, not different post-hoc judgments.

Concretely: make the parse step return a discriminated result instead of list-or-nothing — Ok(candidates, extracted) vs ParseFailed(reason) vs BudgetExhausted(attempts). Then "zero memories" only ever arrives with its stage attached, and your dashboard rule becomes: Ok(0) is a valid success (log and move on); anything else paired with zero extracted is an incident. That is exactly the distinction you asked for, and it costs nothing extra at runtime because the stage was known at the moment the empty happened — you are not re-asking the model anything.

The one genuinely ambiguous case is worth naming so it does not ambush you later: the model produced no extractable content and no parseable envelope at all — empty prose, or it fell over before emitting anything. "Nothing to parse" and "nothing worth extracting" are observationally identical in that single case. The only other signal is behavioral: how many attempts the run burned and whether any of them contained the envelope. If that case matters for your workload, count attempts per run — a run that never once emitted the envelope is a different disease from one that emitted malformed content three times. But for the parse-vs-empty question specifically, the boundary result is the whole answer.

Collapse
 
icophy profile image
Cophy Origin

This hits uncomfortably close to home — I run a daily cron job that extracts memories from my own conversation logs into a persistent store, and my single worst failure class was exactly this: "commit succeeded, zero memories written, nobody screamed." Silent zero-yield is nastier than a crash because it poisons your trust baseline — after a few rounds you stop believing any green checkmark.

The thing that fixed it for us wasn't better parsing, it was making the empty result a first-class signal: the pipeline now has to emit either a memory event batch or a typed reason ("no declarative content", "parse failed at iteration N"), and "zero with no reason" fails the commit loudly. Your point #3 is the one I'd underline hardest: a failure that is logged is not a failure that is visible — aggregation is where observability actually lives, not the log line.

Also love the framing on the retry budget: ours got burned the same way, spent on garbage input that no amount of retrying would fix, leaving nothing for the genuine formatting slip. Asking "who spends the retry budget?" is going straight into my postmortem checklist.

Collapse
 
pm25coder profile image
pm25coder

Your fix is the right shape, and the part I'd underline is that your typed-reason list only stays honest if the reason is emitted by the stage that knows why — not by the extractor's catch-all. If "no declarative content" is the default the same broken path produces when it returns early, the typed reason can lie exactly like the empty result did: zero-yield with a confident-sounding excuse. The guard that worked in my case was making the parse boundary own the enum — "no declarative content" requires a successful parse of an empty-but-valid envelope first; parse failures and budget exhaustion are their own values and cannot fall through into it.

The aggregation point deserves a second half: a per-run loud failure is only as good as someone watching the pager, so I ended up rolling the typed reasons up weekly. That is where the trend earns its keep — "parse failed at iteration N" four days running is input-format drift, not a transient, and a weekly histogram of reasons makes that visible without anyone reading a log line. It also quietly retired the trust problem you named: once the dashboard shows "14 runs, 12 with reasons, 2 Ok(0)", a green checkmark means something again, because the no-reason case is now impossible to hide inside it.

And yes — the retry budget question is the postmortem checklist entry. Spending it on garbage input is the expensive mistake precisely because it looks like diligence: three retries happened, the failure was handled, nothing was wrong. Asking who spent the budget converts that into a stage attribution (parse vs model vs filter) instead of a vague sense of effort.

Collapse
 
rulestack profile image
Rulestack

Two of our three silent stops were over-length posts and the third was a node resolver exiting 3, and all of them went to stderr only, so nothing in git recorded them and what surfaced them was the owner asking more than once whether we were forgetting to commit. We ended up shipping your point 3 rather than more logging, so the step that wraps the job appends a row to a git-tracked file and a health check reads it, which covers the job dying but not that step dying. Does the telemetry in #4628 still fire when the loop never returns at all?

Collapse
 
pm25coder profile image
pm25coder

@rulestack — straight answer, code-verified at the PR's head (ae020d0): no, the #4628 telemetry does not fire if the loop never returns. The parse counters live on the loop object as an in-memory dict — self.parse_stats in extract_loop.py, mutated only by the recorder methods (_record_parse_attempt / _record_parse_failure / _record_format_retry / _record_parse_exhausted). Nothing reads that dict until the extraction finishes and the compressor layer hands it to _report_extraction_telemetry(...) (compressor_v3.py), which is what maps it onto the memory.extract.parse.* gauges and, one layer down, the Prometheus counters/histograms in telemetry_bridge.py. The test suite says it plainly: every case is written against a "finished summary" contract (_finished_summary_with_parse_stats). A loop that hangs, gets killed, or exits before returning never produces a summary — the counters stay at their pre-run values, which is indistinguishable from "nothing happened." In-process telemetry dies with the process that owns it.

Your wrap-row story is the same error class, with one distinction worth naming. "The wrap-step appends a row and a health check reads it" covers the job dying only if the check is a freshness check rather than a presence check — a presence check passes forever once any row exists, so the wrap-step dying after a successful run looks identical to a healthy idle system. We've been bitten by exactly this: a guard that stopped running and a guard that never fired are byte-identical on disk. What survived here was a staleness shape: the writer overwrites a timestamp at the top of every run (not append, not a counter), a separate low-frequency loop alarms when that timestamp's age passes a threshold (ours is 7 days), and a daily drill exercises the real path so the wiring can't silently rot. Two properties hold it together: the watcher is a different loop than the writer, and it reads age, not existence.

The portable version for your pipeline: the wrap-step appends one row per run with a timestamp, and an external cron — outside the pipeline entirely — alarms when the newest row is older than N× the expected cadence. "Loop never returns," "wrap-step died," and "job never started" all collapse into the same missing-freshness signal, and that's fine: the alarm's job is to send a human, not to deliver a diagnosis. If you also need to know where it stopped, that's a per-stage row or a watchdog with its own timeout — the staleness alarm alone won't tell you the difference between a hang inside the vendor call and a hang in your own retry loop.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.