fix: make critic response parsing None-safe#219
Conversation
When Gemini returns an empty response (response.text is None), extract_json() crashed with AttributeError on text.strip(). The critic agent now handles None gracefully, returning an empty CritiqueResult instead of raising. This is defense-in-depth for the thinking model issue (llmsresearch#40) — even if the RuntimeError from gemini.py is caught by tenacity retry and all retries fail, the critic won't crash with an unhelpful AttributeError. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dippatel1994
left a comment
There was a problem hiding this comment.
Clean fix. None-guard added at both the call site (CriticAgent._parse_response) and the underlying utility (extract_json), so any other caller that hits a None or empty VLM response gets the safe path too. Type hints updated to str | None on both, which makes the contract explicit.
Test coverage is exactly what I'd ask for: None, empty string, valid object, valid array, truncated JSON — at both layers. The "truncated JSON returns empty critique" test is particularly nice because it matches a real failure mode from #40.
Approving — this directly addresses the silent revision-loop bypass described in issue #40 and we've seen similar reports from #213.
One small nit (non-blocking, fine in a follow-up): the logger.warning("Critic received None response from VLM") line would be more useful with structured fields (e.g. vlm_provider=self.vlm_provider.name) so it's filterable in production logs. Not a merge blocker.
Thanks for the contribution.
What does this PR do?
When a VLM provider returns
None(e.g. Gemini empty response, OpenRouter refusal withcontent: null),extract_json()crashes withAttributeError: 'NoneType' object has no attribute 'strip'andCriticAgent._parse_response()propagates the crash. This PR adds None guards at both layers so the critic degrades gracefully to an emptyCritiqueResultinstead of crashing.This is defense-in-depth for #40 — complements the provider-level fix in #55 and the pipeline-level fallback in #146 by handling the case at the parsing layer itself.
Type of change
Changes made
paperbanana/core/utils.py: Added earlyif not text: return Noneguard inextract_json(), updated type hint tostr | Nonepaperbanana/agents/critic.py: Added explicitNonecheck at the top of_parse_response(), updated type hint tostr | Nonetests/test_agents/test_critic_parse.py(new): 8 tests coveringextract_json(None, empty, valid, truncated) andCriticAgent._parse_response(None, empty, valid JSON, no-suggestions, truncated JSON)How to test
pytest tests/test_agents/test_critic_parse.py -v— all 8 tests passtest_none_response_returns_empty_critiqueverifies that_parse_response(None)returns aCritiqueResultwithneeds_revision=Falseinstead of crashingChecklist
pytest tests/ -vpassesruff check paperbanana/ mcp_server/ tests/ scripts/passes