Skip to content

fix(rlm): eliminate empty-answer failure; add extended_reasoning + self-consistency#67

Merged
miguelgfierro merged 11 commits into
mainfrom
fix/rlm-empty-answer
Jun 30, 2026
Merged

fix(rlm): eliminate empty-answer failure; add extended_reasoning + self-consistency#67
miguelgfierro merged 11 commits into
mainfrom
fix/rlm-empty-answer

Conversation

@miguelgfierro

Copy link
Copy Markdown
Contributor

Problem

On harder questions the RLM answer path could return an empty answer flagged as valid (no_answer=False, blank body). Root cause (found from the CodeAct transcript): the engine builds a complete answer in a Python variable, but when a sub-llm() output exceeds the REPL's 4000-char stdout cap the printout looks "cut off"; the model then spends its remaining turns re-asking for "the remaining parts," never calls final(), and the loop exhausts and returns nothing — undetectable by the caller. Raising rlm_max_iters did not reliably fix it (some questions diverged even at double the budget).

Changes

  • Detectable no-answer (07a2830) — a blank RLM result is surfaced as no_answer=true with an explanatory note, never an empty string mislabeled as a valid answer.
  • CodeAct turn logging + convergence warnings (b275480) — per-turn DEBUG trace, plus a WARNING when the loop exhausts its budget without calling final().
  • Convergence fix (20878d6) — the system prompt now states stdout truncation is display-only (call final(variable) directly, don't reassemble), and the out-of-iterations fallback re-asks with no tools so a diverged run must emit text instead of an empty string.
  • Scoped prompt (e7e117b) — requires verifying every benchmark/threshold/figure against the documents and forbids stating a value not found in the source; scopes the earlier "commit immediately" guidance so it no longer suppresses verification.
  • Per-request extended_reasoning (b556ffa) — optional AnswerRequest.extended_reasoning runs the engine at twice the configured rlm_max_iters for a single hard request, without a global default bump. RAG ignores it.
  • Self-consistency guard (476f16b, 923a11d) — FLYCANON_RLM_SELF_CONSISTENCY (default 1 = off); when >1, the non-streaming answer path runs the engine N times in parallel and an LLM selector returns the most-grounded / most-consistent answer. Token usage of all runs + the selector is merged. Documented in env_template.

Config / API surface

  • New env var: FLYCANON_RLM_SELF_CONSISTENCY (int, default 1, range 1–8).
  • New request field: AnswerRequest.extended_reasoning (bool, default false; RLM-only — RAG ignores it).

Tests

171 RLM-area unit tests pass, including red-verified tests for the tool-less out-of-iterations fallback, the extended_reasoning budget doubling, and the self-consistency N-runs-and-select.

When the CodeAct loop exhausts rlm_max_iters without ever calling
final(), the engine hands back an empty string with found defaulted to
True, so RLMAnswerService reported answer="" with no_answer=False -- a
silent degenerate non-answer a caller cannot distinguish from a real
answer (the AstraZeneca Q4 case: 251s of compute, empty body, no_answer
False).

Treat a blank (empty or whitespace-only) answer as no_answer=True and
substitute an explanatory note, dropping orphaned citations. This makes
the failure detectable so callers can fall back or retry. Adds unit
tests for the empty and whitespace-only cases.
Adds DEBUG turn-level tracing to the RLM CodeAct loop (per-turn reasoning,
the model-written code block, and the REPL stdout) plus two WARNING signals
for the empty-answer failure mode: the session warns when it exhausts
rlm_max_iters without calling final(), and the answer service warns when it
falls back to no_answer for a blank result. Uses canon's stdlib logging so
the lines flow through pyfly's structured log pipeline; DEBUG tracing is
opt-in and silent in production.
…swer

Q4-class questions diverged into an empty answer: the model synthesised a
complete answer into a variable, but its sub-llm output exceeded the 4000-char
REPL stdout cap, so printing it looked "cut off". The model then spent every
remaining turn re-asking the sub-llm for "the remaining parts" and never called
final(), so the loop exhausted and returned empty.

Two fixes:
- System prompt: state that print() stdout is truncated for display only, that
  a long answer in a variable is held in full even when its printout looks cut
  off, and to call final(variable) immediately without printing to verify.
- Out-of-iters fallback: re-ask with NO tools (the model can no longer run code
  and must emit text) and a larger token budget, so even a diverged run
  reliably produces a substantive answer instead of an empty string.

Verified on the AstraZeneca Q4 case: previously empty after 16 iterations
(251s); now converges via final() at turn 12 with a grounded 3116-char answer.
Adds a unit test asserting the forced-final turn is tool-less.
The convergence fix told the model to call final() immediately and not print
to verify. That was too broad: on a benchmark-computation question it biased
the model to submit an unverified figure, and in the AstraZeneca Q1 re-run RLM
fabricated benchmarks equal to the team's own input numbers and reversed the
conclusion (scored 1.2/5).

Scope the guidance: keep "don't re-print/reassemble an answer that only LOOKS
cut off" (the truncation fix that makes Q4 converge), but replace the blanket
"call final() immediately, don't verify" with a REQUIREMENT to verify every
benchmark/threshold/figure against the document text and to never state a
benchmark not found in the docs (and never assume the benchmark equals the
figure under evaluation).

Verified on the host harness: Q1 now uses the real role-specific benchmarks
(Reps daily >=6, MSL panel >=60, coverage 95/95/75, DSL=TBC) and computes the
correct pass/fail; Q4 still converges with the grounded 90/50-60/200/300 curve
and drops the prior "Gates/Compuertas" fabrication.
Adds an optional `extended_reasoning` flag to AnswerRequest. When true, the
RLM engine runs at twice the configured budget (rlm_max_iters * 2) for that
one request -- a per-request lever for hard multi-fact questions that need
more turns to gather and verify evidence, instead of a blunt global bump of
the default for all traffic. The budget is derived from the configured value
(x2), not hardcoded, so it tracks FLYCANON_RLM_MAX_ITERS. RAG ignores the
flag (single-shot). The dispatcher already forwards the request verbatim, so
no dispatcher signature change is needed.
RLM is unstable run-to-run on hard analytical questions (e.g. the AstraZeneca
benchmark question scored 2.0/3.3/4.5 across three identical-config runs). Its
best-of ceiling clearly beats RAG, but blind you get the variable expected
quality, and a single bad run can fall below RAG.

Adds rlm_self_consistency (env FLYCANON_RLM_SELF_CONSISTENCY, default 1 = off).
When >1, the non-streaming answer path runs the engine N times in parallel and
an LLM selector picks the most-grounded / most-consistent answer, clawing back
the variance at N times the compute. Token usage of every run plus the selector
is merged so cost reflects the full spend. Streaming keeps a single run so live
turns map to one trajectory. Composes with extended_reasoning.
…n in CI

The S3 object-store backend (storage/s3.py) is an optional runtime extra,
but its unit tests and the pyright type-check of that module run
unconditionally in CI. boto3 was previously only present transitively;
that transitive path went away, breaking the Unit tests and Typecheck
gates with unresolved-import / None-client errors unrelated to any route
change. Declaring boto3 in the dev toolchain makes the dev/CI environment
able to test and type-check the S3 backend deterministically.
@miguelgfierro miguelgfierro merged commit 8590abf into main Jun 30, 2026
7 checks passed
@miguelgfierro miguelgfierro deleted the fix/rlm-empty-answer branch June 30, 2026 14:06
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