Skip to content

fix(llm): tolerant JSON parsing, truncation escalation, provider retries, grounding gate - #210

Open
lfnothias wants to merge 8 commits into
mimosa_v2from
propose/llm-response-hardening
Open

fix(llm): tolerant JSON parsing, truncation escalation, provider retries, grounding gate#210
lfnothias wants to merge 8 commits into
mimosa_v2from
propose/llm-response-hardening

Conversation

@lfnothias

Copy link
Copy Markdown
Collaborator

Slice 2 of 4 from #197. Eight commits cherry-picked unchanged from mimosa_v2_lfx, all found while running Mimosa against ASB capsules through OpenRouter. Each carries its own test file; the commit messages hold the measurements.

Problem

Four ways a run lost work that the model had already produced:

  • Temperature rejections through gateways. workflow_factory samples temperature in [0.7, 1.3]; backends capped at 1.0 refuse the upper half. The fallback existed but only recognised error.param, which gateways do not set, so about half of all workflow generations died instead of retrying at 1.0.
  • Discarded plans. _extract_json_from_code_block did a bare json.loads on the fenced block and returned None when there was no fence. A plan with an unescaped newline, a trailing comma, or no fence at all was dropped after three attempts. This was the most common terminal failure in the sample.
  • Truncated responses parsed as complete. A finish_reason="length" response raised nothing and was handed to the parser, which failed with "Unterminated string" six times per run.
  • Upstream faults reported as 400. OpenRouter reports a provider fault as HTTP 400 "Provider returned error"; _is_retryable_error did not match it, so each occurrence cost a whole generation with no backoff.

Separately, literrature_grounding was read at one call site only, so switching it off still left two of the four callers querying the literature, and grounding outcomes were not observable.

Solution

  • A 400 on a request above the 1.0 ceiling is treated as a temperature rejection; at or below it is left alone.
  • sources/utils/llm_json.py: strict parse first, then repair of control characters, interior quotes, trailing commas and trailing prose, in that order; valid JSON is returned unchanged and the original JSONDecodeError is re-raised when nothing parses. The planner also accepts unfenced JSON and JSON after a leading sentence.
  • Truncation doubles max_tokens and retries, at most twice, never past 65536, per call.
  • "Provider returned error" 400s go through the existing retry path.
  • Grounding is gated in perspicacite_client for every caller; skipped calls are recorded as "disabled" and excluded from the hit-rate denominator. Config gains perspicacite_kb_name, perspicacite_mode and perspicacite_max_papers (round-trip covered), applied once in main.py before any component builds a query.

Testing

Fresh uv sync --group dev --python 3.11 on this branch:

17 failed, 301 passed, 4 skipped

The 17 failures are exactly the pre-existing set listed in .github/known-failing-tests.txt on #205. This slice adds 72 passing tests: llm_json_test, temperature_fallback_test, truncation_escalation_test, grounding_telemetry_test.

Backwards compatibility

Valid JSON, in-range temperatures and complete responses take the same path as before. New config fields default to the previous behaviour (web-search pipeline, agentic mode, 5 papers). literrature_grounding defaults to enabled.

🤖 Generated with Claude Code

lfnothias and others added 8 commits September 2, 2026 12:20
…ervable

Found while running Mimosa against ASB capsules on OpenRouter's stealth tier.

Temperature. workflow_factory samples random.uniform(0.7, 1.3) for every
workflow generation, clamping to 1.0 only for Anthropic. Backends that cap at
1.0 refuse the rest. The retry path already knew how to recover — drop to 1.0,
retry — but _is_temperature_error only read error.param, which OpenAI-style
APIs set and gateways do not: OpenRouter forwards the refusal as a bare 400
whose metadata.raw is the string "ERROR". So the recovery never fired and the
task aborted instead. Measured against stealth/ox-alpha: temperature 1.0 and
0.7 succeed, 1.3 returns that 400 — roughly half of all generations died.
Detection now also treats a 400 on a request above the 1.0 ceiling as a
temperature rejection; a 400 at or below it is left alone so real bad-requests
are not masked.

Grounding. Every failure path in query_perspicacite returns None and each
caller substitutes a "no relevant scientific context" string, so a run where
grounding failed outright produces the same artifacts as a grounded one.
Added a per-attempt ledger and grounding_stats(), folded into run_metrics.json,
so a run can be shown to have been grounded rather than assumed to have been.

Also:
- perspicacite_kb_name / _mode / _max_papers config knobs. kb_name was
  hardcoded None, so retrieval always took the web-search pipeline and the
  local knowledge bases were unreachable. Default stays None — behaviour
  unchanged unless set. For benchmarks, never scope it to a KB built from the
  paper under reproduction; that hands the agent the graded values.
- Persist reasoning_effort only when it was actually sent. It is gated to the
  o1/o3/gpt-5 families, but the configured value was recorded for every model,
  putting a parameter in the run's provenance the provider never received.
- Precheck now names save_logprobs when it is on and every probe failed. The
  probe pairs logprobs with require_parameters, so an endpoint that simply
  does not advertise logprobs fails with a routing-shaped 404 and the generic
  advice sent operators to the provider allowlist instead.
- Drop _OVERALL_TIMEOUT: defined, documented, never passed to httpx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 6499f03)
The flag was read in one place — orchestrator._ground_with_perspicacite.
planner.py and the judge's grounding evaluator (via verifier.py and
generic.py) call query_perspicacite directly, so turning it off still left two
of the four call sites querying the literature. The recorded workaround was to
point PERSPICACITE_API_URL at an unreachable host, which is also what the
fair-eval notes in the dev hub prescribe for benchmark runs.

Gating in the client instead covers every caller from one place. Skipped calls
are recorded as "disabled" and excluded from the hit-rate denominator, so a
deliberately ungrounded run is not reported as a run whose grounding failed.

Default stays enabled; behaviour is unchanged unless the flag is set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit cf34548)
Observed running ASB capsules against stealth/ox-alpha: the planner failed all
three attempts with "Failed to extract valid JSON from LLM response" / "Invalid
control character at …" and the task was abandoned. The model had pasted a
multi-line span straight into a string value.

_extract_json_from_code_block called bare json.loads on the fenced block, so an
otherwise complete plan was discarded over an unescaped newline. The repair
ladder that already exists in onboard_cli.py never covered this path.

Adds sources/utils/llm_json.loads_llm_json, ported from ASB's llm_pipeline
helper, which solved the same problem against the same model family. It repairs
raw control characters, bare interior quotes, and trailing prose after a
complete object — but only after a strict parse fails, so valid JSON is
returned byte-for-byte unchanged, and it re-raises the original JSONDecodeError
when nothing parses so callers still see the true defect.

The per-claim verifier and workflow_info parse LLM JSON the same way and are
candidates for the same treatment; left alone here because neither was observed
failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 0f056aa)
lane1_t0 (p_iimn) ran with the control-character repair already in place and
still lost its plan after three attempts:

    Expecting property name enclosed in double quotes: line 47 column 64

That message reads like a quoting fault; it is a trailing comma — the one JSON
forbids and both JavaScript and Python allow. Six occurrences in that one run.

strip_trailing_commas drops a comma followed only by } or ], tracking string
state so commas inside values survive. The ladder now tries strict, then
string-repair, then comma-strip, then both — a real response can carry a pasted
multi-line span and a trailing comma at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 926ae7e)
_extract_json_from_code_block scanned only for ```json fences and returned None
otherwise, which the caller turns into "Failed to extract valid JSON from LLM
response" and, after three attempts, abandons the task.

A model told to answer in JSON frequently just answers in JSON. Verified
against the persisted plan_creator response from the failed lane1_t1 run: a
valid 7558-character plan object, two top-level keys, three steps, no fence —
discarded. Same failure on lane2_t0. That made it the most common terminal
failure in the sample, ahead of anything model-quality related.

Now tries the fenced block first (unchanged when present), then the bare
response, then from the first brace so a leading "Here is the plan:" does not
cost the plan either. Still returns None when there is genuinely no JSON.

Re-running the extractor over that saved response now recovers the plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit a2b7598)
Same behaviour, spelled out as the three cases it actually handles: opening
quote, closing quote, unescaped interior quote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 9fb0bca)
A response cut off at max_tokens arrives with finish_reason="length" and no
exception, and the caller parses it as if it were complete. Reasoning models
make this routine: the budget is spent on reasoning before any content is
emitted, so a budget that would comfortably fit the answer still returns a
fragment.

Observed on the p_iimn re-run against stealth/ox-alpha, with every JSON repair
already in place: the planner call returned Completion: 8192 against a
max_tokens of exactly 8192, four truncation warnings in one run, and JSON that
ended mid-string. The parser failed six times with "Unterminated string" and
the task was abandoned. No repair recovers a document that was never finished.

The budget now doubles and retries, at most twice and never past 65536. The
config value is not mutated, so escalation is per-call. When it is still
truncated after the last escalation the warning says so explicitly instead of
suggesting the operator "consider increasing max_tokens" — by then that advice
has already been taken automatically.

This is the same shape as the existing context-window handler, which halves an
oversized prompt and retries; that one guards the input side, this one the
output side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 78ecb18)
Once the temperature fallback landed, every temp>1.0 generation recovered:
the log shows 1.20, 1.10, 1.24, 1.08 each falling back to 1.0 and then
"generated in Ns". The generation failures that remained were all at
temperature <= 1.0 — a different cause.

That cause is OpenRouter reporting an upstream fault as HTTP 400 with
message "Provider returned error" and metadata.raw "ERROR". The 400 makes it
look like a malformed request. It is not: six identical calls at temperature
0.85 with a full-size workflow prompt succeeded in isolation while the same
shape was failing intermittently under four concurrent lanes. Semantically it
is a 502.

_is_retryable_error did not match that wording, so it raised immediately with
no backoff and each occurrence cost a whole workflow generation — the single
largest remaining source of lost work in the sample.

The match is deliberately narrow, on the gateway's own phrasing rather than on
400s in general, so a genuinely malformed request is not retried in a loop; it
would keep failing and still exhaust the existing retry ceiling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 3aa1b8c)
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.

1 participant