fix: avoid AttributeError when LLM returns null content as final response#1972
fix: avoid AttributeError when LLM returns null content as final response#1972yakir-shriker wants to merge 2 commits intoHolmesGPT:masterfrom
Conversation
✅ Deploy Preview for holmes-docs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughNormalize provider-returned ChangesNormalize LLM content across tool-calling + interactive flows
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.Comment |
0773ba7 to
308442c
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@holmes/core/tool_calling_llm.py`:
- Around line 596-614: The interactive flow constructs LLMResult using
terminal_data["content"] without normalizing None, causing crashes; update the
code path that creates LLMResult(result=terminal_data["content"], ...) in
interactive.py (the block around the call_stream/response assembly where
LLMResult is constructed) to coalesce content to an empty string when
terminal_data["content"] is None (i.e., if content is None: content = ""), or
factor this normalization into a shared helper used by both
ToolCallingLLM.call() and the interactive response assembly and call that helper
before constructing LLMResult.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: a2835885-51dd-4530-b456-4ece85da2ad1
📒 Files selected for processing (2)
holmes/core/tool_calling_llm.pytests/test_tool_calling_llm.py
308442c to
72cd95a
Compare
…onse Signed-off-by: Yakir Shriker <yakirshr@gmail.com>
65c2e8f to
12e1e41
Compare
Problem
holmes investigate(CLI and source plugins) crashes with:It happens when the LLM finishes a tool-calling conversation by emitting a final assistant message whose
contentisnullinstead of an empty string —stop_reason=end_turn, no further tool calls, no text. The streamedANSWER_ENDpayload then carriescontent=None, which is forwarded verbatim intoLLMResult.result.Anything downstream that assumes
result.resultis astrthen breaks:result.result.replace(...)inholmes/main.py— the originalAttributeError.holmes/interactive.pybuilds the sameLLMResult(result=terminal_data["content"], ...), then forwardsresponse.resulttofeedback.metadata.add_llm_response(...)andtrace_span.log(output=...), soNonepropagates to those consumers too.markdown_to_plain_text(result_data.result)inwrite_back_result— sameAttributeError, different caller.LLMResult.resultinto a Pydanticstrfield (e.g.ChatResponse.analysis) —validation error: Input should be a valid string.The same root cause was already addressed for the
/api/chatendpoint in #1789 (issue #1676), wherefinal_response.content or ""was added inserver.py. That fix only covers the streaming server path; every other place that builds anLLMResultfrom the streamed terminal data still propagatesNone. This PR moves the fix one layer down so all consumers benefit and the guard doesn't have to be repeated at every call site.Root cause
call_stream(...)inholmes/core/tool_calling_llm.pyyieldsStreamEvents.ANSWER_ENDwithdata["content"] = response_message.content, whereresponse_message.contentisOptional[str]. Two call sites then constructLLMResultdirectly from that payload:ToolCallingLLM.call(...)inholmes/core/tool_calling_llm.py— used by the CLI, source plugins, and SDK callers.run_interactive_loop(...)inholmes/interactive.py— used byholmes ask/ interactive mode.Both pass
terminal_data["content"]straight through:LLMResult.resultis typedOptional[str], so Pydantic acceptsNoneand the failure surfaces only at the consumer.Fix
Extract a single helper next to
class LLMResultthat does theNone→""coalesce and emits aWARNINGlog when this happens (so the empty-content behavior is observable rather than silent), then call it from every site that builds anLLMResultfrom the streamed terminal data:This keeps
LLMResult.resulteffectively a non-null string for every consumer (CLI, interactive, source plugins, SDK, server) without changing the public type. Same pattern as #1789, applied at theLLMResultboundary instead of inside the streaming server.Other
LLMResult(...)constructionsAudited and confirmed not affected:
holmes/core/tool_calling_llm.py(frontend-tool-paused branch) — hardcodedresult="Investigation paused: ..."literal.holmes/interactive.py::save_conversation_to_file— deliberately setsresult=None("No single result in interactive mode").holmes/checks/checks.py,holmes/checks/checks_api.py— built from check results (result.message,result.rationale or result.message), not from streamedterminal_data.holmes/plugins/sources/pagerduty/__init__.py— test/debug literal"This is a test".Tests
tests/test_tool_calling_llm.py— two new test classes that fail on master with the originalAttributeErrorand pass with this change:TestCoalesceLLMContent— direct unit tests for the helper:test_none_becomes_empty_string_and_warns—Noneis coalesced to""and the warning is logged.test_string_passes_through_without_warning— non-Nonestrings pass through and no warning is emitted.TestEmptyLLMContent— integration tests throughToolCallingLLM.call(...):test_call_no_crash_on_none_content_no_tools— LLM returnscontent=Noneimmediately. Includes a smoke-test of the exact downstream call site (result.result.replace("\n", "\n\n")) that crashes inholmes/main.py.test_call_no_crash_after_tool_execution— covers the post-tool-execution path: a tool runs successfully, then the model's final summary turn returnscontent=None. Assertsresult.result == "", the tool call is preserved, and the warning is emitted.The full
tests/test_tool_calling_llm.pysuite (45 tests) passes locally.Scope
holmes/core/tool_calling_llm.py(helper + one call site).holmes/interactive.pyto use the helper.tests/test_tool_calling_llm.py.LLMResult.resultstaysOptional[str]so callers that explicitly acceptNone(e.g.save_conversation_to_file) still work; in practice it is now always astraftercall(...)/ the interactive loop.Related
or ""fix from Handle null analysis result in chat endpoint response #1789 but applied at theLLMResultboundary so CLI / interactive / source plugins / SDK callers all benefit, not just/api/chat.Summary by CodeRabbit
Bug Fixes
Tests