Skip to content

feat: Migrate LLM calls from sync to async for improved concurrency - #1716

Closed
mainred wants to merge 1 commit into
masterfrom
async-call-llm
Closed

mainred wants to merge 1 commit into
masterfrom
async-call-llm

Conversation

@mainred

@mainred mainred commented Mar 9, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This PR migrates all LLM API calls from synchronous to asynchronous using litellm.acompletion. This enables better performance when handling multiple requests at the same time.

What Changed

Core LLM Layer:

  • LLM.completion() now uses async/await with litellm.acompletion
  • All tool calling methods are now async
  • Tool execution runs in parallel using asyncio instead of threads

Integration Points:

  • Server /api/chat endpoint is now async (better for concurrent requests)
  • CLI commands use asyncio.run() to wrap async calls
  • Interactive mode runs async calls in background threads
  • Tests updated to use AsyncMock for async methods

Benefits

  • Server deployments: Can handle multiple requests concurrently without blocking
  • Better resource usage: Single event loop instead of multiple thread pools
  • Improved responsiveness: Concurrent tool execution with asyncio

Backward Compatibility

CLI commands work exactly the same - they wrap async calls with asyncio.run() so there's no change in behavior for single-command usage.

Files Modified

  • holmes/core/llm.py - Async LLM completion
  • holmes/core/tool_calling_llm.py - Async tool calling with asyncio tasks
  • holmes/core/truncation/ - Async context limiting and compaction
  • server.py - Async FastAPI endpoint
  • holmes/main.py, holmes/interactive.py - Async wrappers for CLI
  • Tests updated for async execution

Summary by CodeRabbit

Release Notes

  • New Features

    • Added asynchronous LLM completion pathway for improved performance and non-blocking operations.
    • Implemented async streaming support for tool execution results.
    • Chat API endpoint now supports asynchronous request handling with streaming responses.
    • Added configuration example builder utility for Pydantic models.
  • Refactor

    • Modernized tool invocation flow with asynchronous execution.
    • Improved server streaming infrastructure with storage cleanup handling.
    • Enhanced metadata tracking with supports_additional_system_prompt flag.
  • Tests

    • Expanded test coverage for prompt component generation and configuration utilities.

@github-actions

github-actions Bot commented Mar 9, 2026 •

Copy link
Copy Markdown
Contributor

✅ Docker images ready for b6c5b5a7 (built in 6m 11s)

⚠️ Warning: does not support ARM (ARM images are built on release only - not on every PR)

Use these tags to pull the images for testing.

📋 Copy commands

⚠️ Temporary images are deleted after 30 days. Copy to a permanent registry before using them:

gcloud auth configure-docker us-central1-docker.pkg.dev
docker pull us-central1-docker.pkg.dev/robusta-development/temporary-builds/holmes:b6c5b5a7
docker tag us-central1-docker.pkg.dev/robusta-development/temporary-builds/holmes:b6c5b5a7 me-west1-docker.pkg.dev/robusta-development/development/holmes-dev:b6c5b5a7
docker push me-west1-docker.pkg.dev/robusta-development/development/holmes-dev:b6c5b5a7
docker pull us-central1-docker.pkg.dev/robusta-development/temporary-builds/holmes-operator:b6c5b5a7
docker tag us-central1-docker.pkg.dev/robusta-development/temporary-builds/holmes-operator:b6c5b5a7 me-west1-docker.pkg.dev/robusta-development/development/holmes-operator-dev:b6c5b5a7
docker push me-west1-docker.pkg.dev/robusta-development/development/holmes-operator-dev:b6c5b5a7

Patch Helm values in one line (choose the chart you use):

HolmesGPT chart:

helm upgrade --install holmesgpt ./helm/holmes \
  --set registry=me-west1-docker.pkg.dev/robusta-development/development \
  --set image=holmes-dev:b6c5b5a7 \
  --set operator.registry=me-west1-docker.pkg.dev/robusta-development/development \
  --set operator.image=holmes-operator-dev:b6c5b5a7

Robusta wrapper chart:

helm upgrade --install robusta robusta/robusta \
  --reuse-values \
  --set holmes.registry=me-west1-docker.pkg.dev/robusta-development/development \
  --set holmes.image=holmes-dev:b6c5b5a7 \
  --set holmes.operator.registry=me-west1-docker.pkg.dev/robusta-development/development \
  --set holmes.operator.image=holmes-operator-dev:b6c5b5a7

@github-actions

github-actions Bot commented Mar 9, 2026 •

Copy link
Copy Markdown
Contributor

📂 Previous Runs

📜 Run @ 093bcee (#22844785556)

✅ Results of HolmesGPT evals

Automatically triggered by commit 093bcee on branch async-call-llm

View workflow logs

Results of HolmesGPT evals

  • ask_holmes: 0/10 test cases were successful, 10 regressions
Status Test case Time Turns Tools Cost Total tokens Input Output Cached Non-cached Reasoning Max output Compactions
❌ 09_crashpod 0.0s — — — — — — — — — — —
❌ 101_loki_historical_logs_pod_deleted 0.0s — — — — — — — — — — —
❌ 111_pod_names_contain_service 0.0s — — — — — — — — — — —
❌ 112_find_pvcs_by_uuid 0.0s — — — — — — — — — — —
❌ 12_job_crashing 0.0s — — — — — — — — — — —
❌ 176_network_policy_blocking_traffic_no_runbooks 0.0s — — — — — — — — — — —
❌ 227_count_configmaps_per_namespace[0] 0.0s — — — — — — — — — — —
❌ 24_misconfigured_pvc 0.0s — — — — — — — — — — —
❌ 43_current_datetime_from_prompt 0.0s — — — — — — — — — — —
❌ 61_exact_match_counting 0.0s — — — — — — — — — — —
Total 0.0s avg — avg — avg — — — — — — — — —

Benchmark comparison unavailable: No ci-benchmark experiments found

Benchmark Comparison Details

Baseline: latest ci-benchmark experiment on master

Status: No ci-benchmark experiments found

Comparison indicators:

  • ±0% — diff under 10% (within noise threshold)
  • ↑N%/↓N% — diff 10-25%
  • ↑N%/↓N% — diff over 25% (significant)

⚠️ 10 Failures Detected

📜 Run @ 59e145c (#22843602701)

✅ Results of HolmesGPT evals

Automatically triggered by commit 59e145c on branch async-call-llm

View workflow logs

Results of HolmesGPT evals

  • ask_holmes: 0/10 test cases were successful, 10 regressions
Status Test case Time Turns Tools Cost Total tokens Input Output Cached Non-cached Reasoning Max output Compactions
❌ 09_crashpod 0.0s — — — — — — — — — — —
❌ 101_loki_historical_logs_pod_deleted 0.0s — — — — — — — — — — —
❌ 111_pod_names_contain_service 0.0s — — — — — — — — — — —
❌ 112_find_pvcs_by_uuid 0.0s — — — — — — — — — — —
❌ 12_job_crashing 0.0s — — — — — — — — — — —
❌ 176_network_policy_blocking_traffic_no_runbooks 0.0s — — — — — — — — — — —
❌ 227_count_configmaps_per_namespace[0] 0.0s — — — — — — — — — — —
❌ 24_misconfigured_pvc 0.0s — — — — — — — — — — —
❌ 43_current_datetime_from_prompt 0.0s — — — — — — — — — — —
❌ 61_exact_match_counting 0.0s — — — — — — — — — — —
Total 0.0s avg — avg — avg — — — — — — — — —

Benchmark comparison unavailable: No ci-benchmark experiments found

Benchmark Comparison Details

Baseline: latest ci-benchmark experiment on master

Status: No ci-benchmark experiments found

Comparison indicators:

  • ±0% — diff under 10% (within noise threshold)
  • ↑N%/↓N% — diff 10-25%
  • ↑N%/↓N% — diff over 25% (significant)

⚠️ 10 Failures Detected


⚠️ Eval Results (with failures)

Automatically triggered by commit a5e0d29 on branch async-call-llm

View workflow logs

Results of HolmesGPT evals

  • ask_holmes: 0/10 test cases were successful, 10 regressions
Status Test case Time Turns Tools Cost Total tokens Input Max input Output Max output Cached Non-cached Reasoning Compactions
❌ 09_crashpod — — — — — — — — — — — — —
❌ 101_loki_historical_logs_pod_deleted — — — — — — — — — — — — —
❌ 111_pod_names_contain_service — — — — — — — — — — — — —
❌ 112_find_pvcs_by_uuid — — — — — — — — — — — — —
❌ 12_job_crashing — — — — — — — — — — — — —
❌ 176_network_policy_blocking_traffic_no_runbooks — — — — — — — — — — — — —
❌ 227_count_configmaps_per_namespace[0] — — — — — — — — — — — — —
❌ 24_misconfigured_pvc — — — — — — — — — — — — —
❌ 43_current_datetime_from_prompt — — — — — — — — — — — — —
❌ 61_exact_match_counting — — — — — — — — — — — — —
Total — avg — avg — avg — — — — — — — — — —
Benchmark Comparison Details

Baseline: latest ci-benchmark experiment on master

Status: Success - 73 test/model combinations loaded

Benchmark experiment:

No benchmark data available for comparison.

Benchmark has no cost, total tokens, cached tokens data. Will appear after the next weekly benchmark run.

Comparison indicators:

  • ±0% — diff under 10% (within noise threshold)
  • ↑N%/↓N% — diff 10-25%
  • ↑N%/↓N% — diff over 25% (significant)

⚠️ 10 Failures Detected

📖 Legend
Icon Meaning
✅ The test was successful
➖ The test was skipped
⚠️ The test failed but is known to be flaky or known to fail
🚧 The test had a setup failure (not a code regression)
🔧 The test failed due to mock data issues (not a code regression)
🚫 The test was throttled by API rate limits/overload
❌ The test failed and should be fixed before merging the PR
🔄 Re-run evals manually

⚠️ Warning: /eval comments always run using the workflow from master, not from this PR branch. If you modified the GitHub Action (e.g., added secrets or env vars), those changes won't take effect.

To test workflow changes, use the GitHub CLI or Actions UI instead:

gh workflow run eval-regression.yaml --repo HolmesGPT/holmesgpt --ref async-call-llm -f markers=regression -f filter=

Option 1: Comment on this PR with /eval:

/eval
tags: regression

Or with more options (one per line):

/eval
model: gpt-4o
tags: regression
id: 09_crashpod
iterations: 5

Run evals on a different branch (e.g., master) for comparison:

/eval
branch: master
tags: regression
Option Description
model Model(s) to test (default: same as automatic runs)
tags Pytest tags / markers (no default - runs all tests!)
id Eval ID / pytest -k filter (use /list to see valid eval names)
iterations Number of runs, max 10
branch Run evals on a different branch (for cross-branch comparison)

Quick re-run: Use /rerun to re-run the most recent /eval on this PR with the same parameters.

Option 2: Trigger via GitHub Actions UI → "Run workflow"

Option 3: Add PR labels to include extra evals (applies to both automatic runs and /eval comments):

Label Effect
evals-tag-<name> Run tests with tag <name> alongside regression
evals-id-<name> Run a specific eval by test ID
evals-model-<name> Override the model (use model list name, e.g. sonnet-4.5)

Examples: evals-tag-easy, evals-id-09_crashpod, evals-model-sonnet-4.5

🏷️ Valid tags

benchmark, chain-of-causation, compaction, confluence, context_window, coralogix, counting, database, datadog, datetime, db-connectors, easy, elasticsearch, embeds, fast, frontend, grafana, hard, integration, kafka, kubernetes, leaked-information, logs, loki, medium, metrics, network, newrelic, no-cicd, numerical, one-test, port-forward, prometheus, question-answer, regression, runbooks, slackbot, storage, toolset-limitation, traces, transparency

🤖 Valid models

deepseek-chat, deepseek-r1-reasoner, deepseek-reasoner, deepseek-v3.2-chat, gemini-3-flash-preview, gemini-3-pro-preview, gemini-3.1-pro-preview, gpt-4.1, gpt-5.2-high-reasoning, gpt-5.3-codex, gpt-5.4, haiku-4.5, kimi-2.5, kimi-2.5-openrouter, opus-4.5, opus-4.6, qwen-next-80B-instruct, qwen-next-80B-thinking, sonnet-4.5, sonnet-4.6


Commands: /eval · /rerun · /list

CLI: gh workflow run eval-regression.yaml --repo HolmesGPT/holmesgpt --ref async-call-llm -f markers=regression -f filter=

@coderabbitai

coderabbitai Bot commented Mar 9, 2026 •

Copy link
Copy Markdown
Contributor

Walkthrough

This PR introduces asynchronous execution patterns throughout the core LLM and tool-calling infrastructure, converts the FastAPI server chat endpoint to async with streaming support, adds configuration and utility helpers, and applies extensive formatting improvements across the codebase.

Changes

Cohort / File(s) Summary
Core async LLM migration
holmes/core/llm.py
Added abstract async acompletion() method to LLM base class and implemented in DefaultLLM with Litellm delegation. Refactored completion kwargs preparation and result validation into shared helper methods _prepare_completion_kwargs() and _validate_completion_result() to support both sync and async paths.
Tool-calling async conversion
holmes/core/tool_calling_llm.py
Converted core methods to async: _execute_tool_decisions(), _invoke_llm_tool_call(), and call_stream() now return AsyncGenerator. Replaced ThreadPoolExecutor with asyncio.gather for parallel tool invocations. Added helper methods _should_include_restricted_tools() and _get_tools() to manage tool availability based on runbook state.
Server async streaming
server.py
Migrated /chat endpoint to async with awaited LLM calls and streaming via AsyncGenerator. Introduced _stream_with_storage_cleanup() async wrapper for proper cleanup. Added request-context passthrough, enhanced error handling, logging setup, TLS certificate handling, and tool result storage lifecycle management.
Stream utilities and formatters
holmes/utils/stream.py, holmes/core/truncation/...*
Converted stream_chat_formatter() to async generator accepting AsyncGenerator input. Reformatted error paths and multi-line expressions in compaction and context window limiter modules without semantic changes.
Configuration and status utilities
holmes/utils/holmes_status.py, holmes/utils/pydantic_utils.py
Added supports_additional_system_prompt field to HolmesMetadata dataclass with default True. Introduced build_config_example() helper to generate JSON-serializable Pydantic model examples and internal _extract_base_model_subclass() for type extraction.
TUI and CLI improvements
holmes/toolset_config_tui.py, holmes/interactive.py, holmes/main.py
Added import of run_toolset_config_tui to interactive.py. Reformatted TUI function signatures and string literals across multiple lines. Updated various CLI command initializations with multi-line formatting.
Public API surface updates
holmes/checks/__init__.py
Exposed CheckRunner, execute_check, and load_checks_config at package level by importing from holmes.checks.checks submodule.
Widespread formatting and cleanup
holmes/plugins/toolsets/*, holmes/core/tools*.py, holmes/core/azure_token.py, holmes/core/json_schema_coerce.py, tests/*, examples/*, experimental/*, .claude/skills/*, docs/*, conftest.py, scripts/*
Extensive reformatting: multi-line function signatures, wrapped conditionals and returns, reflow of string literals and collections, import reordering/cleanup, blank line adjustments, and removal of unused imports. No semantic logic changes; purely stylistic improvements for readability and consistency.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • ROB-1933 tool approval workflow #919 — Parallel async migration of tool-calling and streaming paths in holmes/core/tool_calling_llm.py, including similar conversion of call_stream and _invoke_llm_tool_call to async.
  • Add metadata to HolmesStatus #1591 — Adds HolmesMetadata dataclass and metadata inclusion in Holmes status upsert, overlapping with supports_additional_system_prompt field addition.
  • Enhance prompt caching #916 — Modifies DefaultLLM completion path in holmes/core/llm.py for cache control and related completion handling.

Suggested labels

enhancement, refactoring, async

Suggested reviewers

  • aantn
  • moshemorad
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Migrate LLM calls from sync to async for improved concurrency' accurately summarizes the main architectural change in the PR, which converts LiteLLM completion calls and tooling from synchronous to asynchronous APIs.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

📝 Coding Plan
  • Generate coding plan for human review comments

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can customize the high-level summary generated by CodeRabbit.

Configure the reviews.high_level_summary_instructions setting to provide custom instructions for generating the high-level summary.

@netlify

netlify Bot commented Mar 9, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for holmes-docs ready!

Name Link
🔨 Latest commit a5e0d29
🔍 Latest deploy log https://app.netlify.com/projects/holmes-docs/deploys/69bab71db12e6c000845361d
😎 Deploy Preview https://deploy-preview-1716--holmes-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@mainred
mainred requested review from aantn, Copilot and moshemorad and removed request for moshemorad March 9, 2026 07:53
@github-actions

github-actions Bot commented Mar 9, 2026 •

Copy link
Copy Markdown
Contributor

🔬 CLI Performance Benchmark

🟡 Startup Time (no LLM)

Measures holmes version execution time (imports + initialization)

Metric PR Master Change
Cold Start 11.21s 11.01s +1.8%
Warm Mean 5.19s 4.89s +6.2%
Warm Min 5.12s 4.85s
Warm Max 5.25s 4.95s

🟡 Full CLI with LLM

Measures holmes ask execution time (OpenRouter + Haiku 4.5)

Metric PR Master Change
Cold Start 32.06s 30.29s +5.9%
Warm Mean 8.11s 7.86s +3.2%
Warm Min 7.34s 7.54s
Warm Max 8.62s 8.27s

PR: a9d8a739 | Master: 39b85091 | Iterations: 5

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates the Holmes LLM integration from synchronous LiteLLM calls to async (litellm.acompletion) to improve concurrency in server deployments, while keeping the CLI usable by wrapping async calls with asyncio.run().

Changes:

  • Converted core LLM completion + tool-calling flows to async/await.
  • Updated server /api/chat handler to async and updated non-streaming call path to await async LLM calls.
  • Made conversation compaction and input context window limiting async to match the new async LLM API.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
server.py Makes /api/chat async and awaits async message calls.
holmes/main.py Wraps async LLM calls in CLI commands via asyncio.run().
holmes/interactive.py Adapts interactive execution to call async ai.call() via asyncio.run().
holmes/core/truncation/input_context_window_limiter.py Converts context window limiting to async and awaits compaction.
holmes/core/truncation/compaction.py Converts compaction to async and awaits llm.completion().
holmes/core/transformers/llm_summarize.py Calls async completion from the summarizer via asyncio.run().
holmes/core/tool_calling_llm.py Converts prompt/messages/call/call_stream APIs to async.
holmes/core/llm.py Converts LLM.completion() / DefaultLLM.completion() to async and uses litellm.acompletion.
Comments suppressed due to low confidence (3)

holmes/core/llm.py:136

  • LLM.completion is now async, but the repo still contains LLM subclasses with a synchronous def completion(...) (e.g., tests/conftest.py:118, examples/custom_llm.py:35). Those will now be awaited by ToolCallingLLM and fail at runtime. Update these implementations to async def completion(...) (or provide a sync adapter) as part of this migration.
    async def completion(
        self,
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict[str, Any]]] = [],
        tool_choice: Optional[Union[str, dict]] = None,
        response_format: Optional[Union[dict, Type[BaseModel]]] = None,
        temperature: Optional[float] = None,
        drop_params: Optional[bool] = None,
        stream: Optional[bool] = None,
    ) -> Union[ModelResponse, CustomStreamWrapper]:
        pass

holmes/core/llm.py:134

  • The abstract LLM.completion still uses a mutable default (tools: ... = []). This can create shared state across calls/implementations and is inconsistent with DefaultLLM.completion (which defaults to None). Use None as the default and normalize inside the method.
    async def completion(
        self,
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict[str, Any]]] = [],
        tool_choice: Optional[Union[str, dict]] = None,
        response_format: Optional[Union[dict, Type[BaseModel]]] = None,
        temperature: Optional[float] = None,
        drop_params: Optional[bool] = None,
        stream: Optional[bool] = None,

holmes/core/tool_calling_llm.py:395

  • Making prompt_call/messages_call async is a breaking API change: there are still synchronous call sites in the repo (e.g. tests and examples) that call ai.messages_call(...) without await/asyncio.run, which will now return a coroutine and fail at runtime. Either update those call sites in this PR or provide backward-compatible sync wrappers (e.g., messages_call_sync/prompt_call_sync).
    async def prompt_call(
        self,
        system_prompt: str,
        user_prompt: str,
        response_format: Optional[Union[dict, Type[BaseModel]]] = None,
        trace_span=DummySpan(),
        request_context: Optional[Dict[str, Any]] = None,
    ) -> LLMResult:
        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ]
        return await self.call(
            messages,
            response_format=response_format,
            user_prompt=user_prompt,
            trace_span=trace_span,
            request_context=request_context,
        )

    async def messages_call(
        self,
        messages: List[Dict[str, str]],
        response_format: Optional[Union[dict, Type[BaseModel]]] = None,
        trace_span=DummySpan(),
        request_context: Optional[Dict[str, Any]] = None,
    ) -> LLMResult:
        return await self.call(
            messages,
            response_format=response_format,
            trace_span=trace_span,
            request_context=request_context,
        )

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server.py
Comment on lines 314 to 316
@app.post("/api/chat")
def chat(chat_request: ChatRequest, http_request: Request):
async def chat(chat_request: ChatRequest, http_request: Request):
try:

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chat was changed to async def, but it’s passed into ScheduledPromptsExecutor as chat_function. The executor calls self.chat_function(...) synchronously (no await), so it will now receive a coroutine instead of a ChatResponse and scheduled prompts will break. Update the executor (or provide a sync wrapper) so scheduled prompts can invoke the async chat properly.

Copilot uses AI. Check for mistakes.
Comment thread holmes/core/llm.py Outdated
Comment on lines 416 to 432
@@ -428,7 +428,7 @@ def completion(
]

litellm_model_name = self.get_litellm_corrected_name_for_robusta_ai()
result = litellm_to_use.completion(
result = await litellm_to_use.acompletion(
model=litellm_model_name,

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DefaultLLM.completion now calls litellm_to_use.acompletion(...), but BraintrustTracer.wrap_llm() currently only wraps the sync completion path (it sets acreate_fn=None and does not override acompletion). When a tracer is enabled, async completions will bypass tracing. Update the tracer wrapper to support acompletion (e.g., provide acreate_fn/an acompletion wrapper) so observability is preserved after this async migration.

Copilot uses AI. Check for mistakes.
Comment thread holmes/core/truncation/compaction.py Outdated
Comment on lines 60 to 62
async def compact_conversation_history(
original_conversation_history: list[dict], llm: LLM
) -> CompactionResult:

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compact_conversation_history is now async, but there are still synchronous callers in the repo (including tests) that invoke it without await, which will now return a coroutine and break. Update those call sites or keep a sync wrapper for backward compatibility (e.g., compact_conversation_history_sync that calls asyncio.run).

Copilot uses AI. Check for mistakes.
Comment on lines 154 to 156
response = asyncio.run(
self._fast_llm.completion([{"role": "user", "content": full_prompt}])
)

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LLMSummarizeTransformer keeps a shared _fast_llm instance and calls it via asyncio.run(...). In server mode, transformer instances are cached on Tool objects and tools are shared across requests, while tool invocations run concurrently in a thread pool—so multiple threads can call this shared _fast_llm at the same time. Since DefaultLLM.completion mutates instance state (self.args), this can lead to cross-request parameter leakage/races. Consider creating a per-call LLM instance here or guarding the _fast_llm.completion call with a lock / making DefaultLLM.completion side-effect free.

Copilot uses AI. Check for mistakes.
Comment thread server.py
Comment on lines 398 to 404
)
else:
try:
llm_call = ai.messages_call(
llm_call = await ai.messages_call(
messages=messages,
trace_span=chat_request.trace_span,
response_format=chat_request.response_format,

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this handler, ToolCallingLLM.call_stream has been converted to an async generator, but the chat_request.stream branch above still feeds ai.call_stream(...) into stream_chat_formatter, which is implemented as a normal (sync) generator and iterates with for. That combination will raise at runtime. The streaming path needs to be updated to consume an async iterable (e.g., async for) and/or make stream_chat_formatter async so StreamingResponse receives an async iterator.

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
holmes/core/tool_calling_llm.py (1)

438-470: ⚠️ Potential issue | 🟠 Major

Event loop blocking in async tool-calling paths via concurrent.futures.

Both call() and call_stream() use concurrent.futures.ThreadPoolExecutor with as_completed() and future.result(), which block the event loop. Additionally, call_stream() calls synchronous process_tool_decisions() without awaiting, and calls future.result() in the async context. Under FastAPI, this blocks the event loop for the full tool execution duration, stalling unrelated requests.

Replace concurrent.futures.as_completed() and executor.submit() with asyncio.to_thread() and consume with asyncio.as_completed(). Also: call_stream() is missing a return type annotation (should be AsyncGenerator).

Also applies to: 547-576, 941-945, 971-1009, 1086-1107

Additionally, DummySpan() default arguments on lines 368, 387, 414, 792, 1095 create shared instances across calls—use per-call initialization instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/tool_calling_llm.py` around lines 438 - 470, The async
tool-calling paths block the event loop because call() and call_stream() use
concurrent.futures.ThreadPoolExecutor with as_completed()/future.result() and
call_stream() invokes process_tool_decisions() synchronously; fix by converting
executor.submit/as_completed usage to asyncio.to_thread and consuming with
asyncio.as_completed (or use asyncio.create_task + asyncio.as_completed) so you
await thread-bound work without blocking, replace any future.result() calls with
awaiting the asyncio tasks, and ensure process_tool_decisions() is awaited when
invoked from async flows; add the correct return type AsyncGenerator to
call_stream() signature; finally, remove shared-default DummySpan instances by
creating new DummySpan() per call site (initialize inside functions where
DummySpan was a default arg) to avoid shared mutable defaults.
server.py (1)

305-312: ⚠️ Potential issue | 🟠 Major

Convert streaming wrappers to handle async generators.

The recent migration to async LLM calls introduced a bug: ai.call_stream() is now an async def function that yields (an async generator), but stream_chat_formatter() and _stream_with_storage_cleanup() are still sync functions using for and yield from. Attempting to iterate an async generator with a sync for loop raises TypeError: 'async_generator' object is not iterable immediately on any streaming request.

Convert both stream_chat_formatter() (in holmes/utils/stream.py) and _stream_with_storage_cleanup() (in server.py) to async def and use async for instead of for/yield from:

  • stream_chat_formatter: Change for message in call_stream: to async for message in call_stream:
  • _stream_with_storage_cleanup: Change yield from stream_generator to use async for and yield
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server.py` around lines 305 - 312, The streaming wrappers are still
synchronous and can't iterate async generators; change
_stream_with_storage_cleanup and stream_chat_formatter to async functions (async
def) and replace their synchronous iteration/yield logic with async iteration:
in stream_chat_formatter use "async for message in call_stream" instead of
"for", and in _stream_with_storage_cleanup use "async for item in
stream_generator" and "yield item" (instead of yield from), keeping the finally
cleanup (logging and calling storage.__exit__(None, None, None)) intact so
cleanup runs after the async iteration completes.
🧹 Nitpick comments (1)
holmes/core/tool_calling_llm.py (1)

363-395: Avoid sharing a single DummySpan() across calls.

These defaults are evaluated once at import time, so every request without an explicit span reuses the same DummySpan instance. That gets harder to reason about now that these entry points can run concurrently. Default to None and create a fresh span inside each method instead.

♻️ Suggested shape
 async def prompt_call(
     self,
     system_prompt: str,
     user_prompt: str,
     response_format: Optional[Union[dict, Type[BaseModel]]] = None,
-    trace_span=DummySpan(),
+    trace_span=None,
     request_context: Optional[Dict[str, Any]] = None,
 ) -> LLMResult:
+    trace_span = trace_span or DummySpan()
     messages = [
         {"role": "system", "content": system_prompt},
         {"role": "user", "content": user_prompt},
     ]

 async def messages_call(
     self,
     messages: List[Dict[str, str]],
     response_format: Optional[Union[dict, Type[BaseModel]]] = None,
-    trace_span=DummySpan(),
+    trace_span=None,
     request_context: Optional[Dict[str, Any]] = None,
 ) -> LLMResult:
+    trace_span = trace_span or DummySpan()
     return await self.call(
         messages,
         response_format=response_format,
         trace_span=trace_span,
         request_context=request_context,
     )

 async def call(  # type: ignore
     self,
     messages: List[Dict[str, str]],
     response_format: Optional[Union[dict, Type[BaseModel]]] = None,
     user_prompt: Optional[str] = None,
-    trace_span=DummySpan(),
+    trace_span=None,
     tool_number_offset: int = 0,
     request_context: Optional[Dict[str, Any]] = None,
     cancel_event: Optional[threading.Event] = None,
 ) -> LLMResult:
+    trace_span = trace_span or DummySpan()

Also applies to: 409-418

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/tool_calling_llm.py` around lines 363 - 395, The methods
prompt_call and messages_call (and the other similar methods around lines
409-418) currently use a single DummySpan() instance as a default which causes
shared mutable state across concurrent calls; change the signature to default
trace_span: Optional[SpanType] = None and inside each method create a fresh span
when trace_span is None (e.g., trace_span = DummySpan() or equivalent) before
passing it to self.call; update prompt_call, messages_call and the other
mentioned methods to follow this pattern so each invocation gets its own span
instance.
🤖 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/transformers/llm_summarize.py`:
- Around line 154-156: The transformer currently calls asyncio.run() inside
transform(), which will raise RuntimeError when invoked from an existing event
loop; change the transformer to be async-aware by converting transform() into an
async def and replace asyncio.run(self._fast_llm.completion(...)) with await
self._fast_llm.completion(...) (referencing transform() and
_fast_llm.completion()), and update any call sites (e.g., where transformers are
invoked in process_tool_decisions()/call_stream()) to await the async transform;
if changing callers is infeasible, alternatively wrap the completion call with
loop.run_in_executor(...) or provide a synchronous wrapper for
_fast_llm.completion() and call that instead—ensure no use of asyncio.run()
remains.

In `@server.py`:
- Line 315: The ScheduledPromptsExecutor currently calls the async chat function
(self.chat_function(...)) synchronously in _execute_prompt, returning a
coroutine instead of running it; update _execute_prompt to execute the coroutine
and get a real response by running it in an event loop (e.g., use
asyncio.run(...) or create a new event loop and run_until_complete) when
invoking self.chat_function, then use the returned response.metadata as before;
alternatively make _execute_prompt async and await self.chat_function(...) and
ensure the background executor invokes the coroutine properly.

---

Outside diff comments:
In `@holmes/core/tool_calling_llm.py`:
- Around line 438-470: The async tool-calling paths block the event loop because
call() and call_stream() use concurrent.futures.ThreadPoolExecutor with
as_completed()/future.result() and call_stream() invokes
process_tool_decisions() synchronously; fix by converting
executor.submit/as_completed usage to asyncio.to_thread and consuming with
asyncio.as_completed (or use asyncio.create_task + asyncio.as_completed) so you
await thread-bound work without blocking, replace any future.result() calls with
awaiting the asyncio tasks, and ensure process_tool_decisions() is awaited when
invoked from async flows; add the correct return type AsyncGenerator to
call_stream() signature; finally, remove shared-default DummySpan instances by
creating new DummySpan() per call site (initialize inside functions where
DummySpan was a default arg) to avoid shared mutable defaults.

In `@server.py`:
- Around line 305-312: The streaming wrappers are still synchronous and can't
iterate async generators; change _stream_with_storage_cleanup and
stream_chat_formatter to async functions (async def) and replace their
synchronous iteration/yield logic with async iteration: in stream_chat_formatter
use "async for message in call_stream" instead of "for", and in
_stream_with_storage_cleanup use "async for item in stream_generator" and "yield
item" (instead of yield from), keeping the finally cleanup (logging and calling
storage.__exit__(None, None, None)) intact so cleanup runs after the async
iteration completes.

---

Nitpick comments:
In `@holmes/core/tool_calling_llm.py`:
- Around line 363-395: The methods prompt_call and messages_call (and the other
similar methods around lines 409-418) currently use a single DummySpan()
instance as a default which causes shared mutable state across concurrent calls;
change the signature to default trace_span: Optional[SpanType] = None and inside
each method create a fresh span when trace_span is None (e.g., trace_span =
DummySpan() or equivalent) before passing it to self.call; update prompt_call,
messages_call and the other mentioned methods to follow this pattern so each
invocation gets its own span instance.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7707e1b1-2e22-46d3-a552-c983665afb6b

📥 Commits

Reviewing files that changed from the base of the PR and between 39b8509 and 59e145c043443d524fc9b731b93b4818d345b36e.

📒 Files selected for processing (8)
  • holmes/core/llm.py
  • holmes/core/tool_calling_llm.py
  • holmes/core/transformers/llm_summarize.py
  • holmes/core/truncation/compaction.py
  • holmes/core/truncation/input_context_window_limiter.py
  • holmes/interactive.py
  • holmes/main.py
  • server.py

Comment thread holmes/core/transformers/llm_summarize.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
server.py (1)

367-418: ⚠️ Potential issue | 🟠 Major

Close tool_result_storage() when setup fails before the response handoff.

storage.__enter__() happens before create_toolcalling_llm(), get_global_instructions_for_account(), and build_chat_messages(), but cleanup only exists inside the non-streaming finally or the returned streaming generator. Any exception during setup leaks the storage context.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server.py` around lines 367 - 418, The storage context created by
tool_result_storage() is entered via storage.__enter__() but only exited in the
streaming return path or the non-streaming finally, so any exception during
create_toolcalling_llm(), get_global_instructions_for_account(), or
build_chat_messages() will leak the storage; fix by ensuring
storage.__exit__(...) is called on all failure paths—either convert the pattern
to a proper context manager usage (with tool_result_storage() as
tool_results_dir: ...) surrounding create_toolcalling_llm,
get_global_instructions_for_account, and build_chat_messages, or add a
try/except/finally around those setup calls that calls
storage.__exit__(exc_type, exc, tb) on exception before re-raising; reference
tool_result_storage(), storage.__enter__(), storage.__exit__(),
create_toolcalling_llm, and build_chat_messages to locate the change.
holmes/interactive.py (1)

1419-1425: ⚠️ Potential issue | 🟠 Major

Refresh the active tool executor after /config saves.

run_toolset_config_tui() updates the in-memory config, but this session keeps using the already-constructed ai.tool_executor. After a save, /tools and subsequent LLM/tool calls can still run against stale toolset instances until the user restarts interactive mode.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/interactive.py` around lines 1419 - 1425, run_toolset_config_tui()
mutates the in-memory config but the session keeps using the old
ai.tool_executor, causing stale toolsets after a save; after the call where
config is not None, recreate or refresh ai.tool_executor using the updated
config (e.g., invoke the same factory/constructor used elsewhere to build a
ToolExecutor from config) and preserve or reapply preloaded_toolsets
(ai.tool_executor.toolsets) as needed so subsequent /tools and LLM/tool
invocations use the updated toolset instances.
holmes/core/tool_calling_llm.py (1)

363-390: ⚠️ Potential issue | 🟡 Minor

Avoid DummySpan() as a default argument.

Using mutable objects as default arguments is a Python anti-pattern. The same DummySpan instance is reused across all calls to these methods. Default to None and create a fresh DummySpan() inside each method instead.

Suggested fix
 async def prompt_call(
     self,
     system_prompt: str,
     user_prompt: str,
     response_format: Optional[Union[dict, Type[BaseModel]]] = None,
-    trace_span=DummySpan(),
+    trace_span=None,
     request_context: Optional[Dict[str, Any]] = None,
 ) -> LLMResult:
+    trace_span = trace_span or DummySpan()
@@
 async def messages_call(
     self,
     messages: List[Dict[str, str]],
     response_format: Optional[Union[dict, Type[BaseModel]]] = None,
-    trace_span=DummySpan(),
+    trace_span=None,
     request_context: Optional[Dict[str, Any]] = None,
 ) -> LLMResult:
+    trace_span = trace_span or DummySpan()
@@
 async def call(  # type: ignore
     self,
     messages: List[Dict[str, str]],
     response_format: Optional[Union[dict, Type[BaseModel]]] = None,
     user_prompt: Optional[str] = None,
-    trace_span=DummySpan(),
+    trace_span=None,
     tool_number_offset: int = 0,
     request_context: Optional[Dict[str, Any]] = None,
     cancel_event: Optional[threading.Event] = None,
 ) -> LLMResult:
+    trace_span = trace_span or DummySpan()

Also applies to: 409-418

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/tool_calling_llm.py` around lines 363 - 390, The methods
prompt_call and messages_call (and the other method around lines 409-418)
currently use DummySpan() as a default argument which reuses the same instance;
change the signature of these functions to default trace_span:
Optional[SpanType] = None (or trace_span=None) and inside each method do if
trace_span is None: trace_span = DummySpan() before using it; update any other
methods that pass trace_span similarly (the second block at 409-418) to follow
the same pattern so each call gets a fresh DummySpan instance.
holmes/core/llm.py (1)

126-136: ⚠️ Potential issue | 🟠 Major

Update all LLM subclasses to implement async completion() method.

The abstract interface changed from def completion(...) to async def completion(...). ToolCallingLLM now awaits self.llm.completion(...), but three concrete subclasses still have synchronous implementations:

  • examples/custom_llm.py:35 (MyCustomLLM)
  • tests/conftest.py:118 (MockLLM)
  • tests/core/test_feedback.py:41 (MockLLM)

These will fail with TypeError when awaited. Convert all three to async def completion(...).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/llm.py` around lines 126 - 136, The LLM interface changed to
async, but concrete implementations still define synchronous completion methods;
update MyCustomLLM (examples/custom_llm.py) and both MockLLM definitions
(tests/conftest.py and tests/core/test_feedback.py) to use async def
completion(...) matching the signature in holmes/core/llm.py so they can be
awaited by ToolCallingLLM; keep the same parameters and return types
(ModelResponse or CustomStreamWrapper), and ensure any internal calls are
awaited or return awaitable results (e.g., await any sync helpers or wrap return
values appropriately).
tests/test_server_endpoints.py (1)

23-38: ⚠️ Potential issue | 🟠 Major

Finish the async mock migration in the remaining image tests.

/api/chat awaits ai.messages_call(...) at line 402 of server.py, but test_api_chat_with_images and test_api_chat_with_images_advanced_format still use plain MagicMock with synchronous side_effect functions. Awaiting those will fail with TypeError, causing test failures.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_server_endpoints.py` around lines 23 - 38, The image tests fail
because mock_ai.messages_call is awaited in server.py (ai.messages_call) but the
tests test_api_chat_with_images and test_api_chat_with_images_advanced_format
still assign a synchronous MagicMock/side_effect; change those to use AsyncMock
(or set return_value to an AsyncMock) and ensure any side_effect handlers are
async def so awaiting works; update the mocks in tests/test_server_endpoints.py
(mock_ai.messages_call) to mirror the working example used earlier (AsyncMock
returning an object with .result, .tool_calls, .messages, .metadata) so the
awaited call in server.py succeeds.
🧹 Nitpick comments (1)
holmes/utils/stream.py (1)

66-68: Restore the async generator type annotation here.

This public API lost its type hints during the async migration, so mypy can no longer validate callers or yielded item types. As per coding guidelines, "Type hints required (mypy configuration in pyproject.toml)".

Proposed typing cleanup
-from typing import Generator, List, Optional, Union
+from typing import AsyncIterator, Generator, List, Optional, Union
@@
 async def stream_chat_formatter(
-    call_stream,
+    call_stream: AsyncIterator[StreamMessage],
     followups: Optional[List[dict]] = None,
-):
+) -> AsyncIterator[str]:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/utils/stream.py` around lines 66 - 68, The public async generator
stream_chat_formatter lost its return type hint; restore its async generator
annotation by updating the signature of stream_chat_formatter to return
AsyncGenerator[...] (e.g. AsyncGenerator[dict, None]) and ensure AsyncGenerator
(and Optional, List if not already) are imported from typing at the top of
holmes/utils/stream.py so mypy can validate callers and yielded item types.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@holmes/core/llm.py`:
- Around line 126-136: The LLM interface changed to async, but concrete
implementations still define synchronous completion methods; update MyCustomLLM
(examples/custom_llm.py) and both MockLLM definitions (tests/conftest.py and
tests/core/test_feedback.py) to use async def completion(...) matching the
signature in holmes/core/llm.py so they can be awaited by ToolCallingLLM; keep
the same parameters and return types (ModelResponse or CustomStreamWrapper), and
ensure any internal calls are awaited or return awaitable results (e.g., await
any sync helpers or wrap return values appropriately).

In `@holmes/core/tool_calling_llm.py`:
- Around line 363-390: The methods prompt_call and messages_call (and the other
method around lines 409-418) currently use DummySpan() as a default argument
which reuses the same instance; change the signature of these functions to
default trace_span: Optional[SpanType] = None (or trace_span=None) and inside
each method do if trace_span is None: trace_span = DummySpan() before using it;
update any other methods that pass trace_span similarly (the second block at
409-418) to follow the same pattern so each call gets a fresh DummySpan
instance.

In `@holmes/interactive.py`:
- Around line 1419-1425: run_toolset_config_tui() mutates the in-memory config
but the session keeps using the old ai.tool_executor, causing stale toolsets
after a save; after the call where config is not None, recreate or refresh
ai.tool_executor using the updated config (e.g., invoke the same
factory/constructor used elsewhere to build a ToolExecutor from config) and
preserve or reapply preloaded_toolsets (ai.tool_executor.toolsets) as needed so
subsequent /tools and LLM/tool invocations use the updated toolset instances.

In `@server.py`:
- Around line 367-418: The storage context created by tool_result_storage() is
entered via storage.__enter__() but only exited in the streaming return path or
the non-streaming finally, so any exception during create_toolcalling_llm(),
get_global_instructions_for_account(), or build_chat_messages() will leak the
storage; fix by ensuring storage.__exit__(...) is called on all failure
paths—either convert the pattern to a proper context manager usage (with
tool_result_storage() as tool_results_dir: ...) surrounding
create_toolcalling_llm, get_global_instructions_for_account, and
build_chat_messages, or add a try/except/finally around those setup calls that
calls storage.__exit__(exc_type, exc, tb) on exception before re-raising;
reference tool_result_storage(), storage.__enter__(), storage.__exit__(),
create_toolcalling_llm, and build_chat_messages to locate the change.

In `@tests/test_server_endpoints.py`:
- Around line 23-38: The image tests fail because mock_ai.messages_call is
awaited in server.py (ai.messages_call) but the tests test_api_chat_with_images
and test_api_chat_with_images_advanced_format still assign a synchronous
MagicMock/side_effect; change those to use AsyncMock (or set return_value to an
AsyncMock) and ensure any side_effect handlers are async def so awaiting works;
update the mocks in tests/test_server_endpoints.py (mock_ai.messages_call) to
mirror the working example used earlier (AsyncMock returning an object with
.result, .tool_calls, .messages, .metadata) so the awaited call in server.py
succeeds.

---

Nitpick comments:
In `@holmes/utils/stream.py`:
- Around line 66-68: The public async generator stream_chat_formatter lost its
return type hint; restore its async generator annotation by updating the
signature of stream_chat_formatter to return AsyncGenerator[...] (e.g.
AsyncGenerator[dict, None]) and ensure AsyncGenerator (and Optional, List if not
already) are imported from typing at the top of holmes/utils/stream.py so mypy
can validate callers and yielded item types.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ec5b915a-ba79-496c-aaa3-c94689a8dacc

📥 Commits

Reviewing files that changed from the base of the PR and between 59e145c043443d524fc9b731b93b4818d345b36e and 093bcee.

📒 Files selected for processing (11)
  • holmes/core/llm.py
  • holmes/core/tool_calling_llm.py
  • holmes/core/transformers/llm_summarize.py
  • holmes/core/truncation/compaction.py
  • holmes/core/truncation/input_context_window_limiter.py
  • holmes/interactive.py
  • holmes/main.py
  • holmes/utils/stream.py
  • server.py
  • tests/core/transformers/test_llm_summarize.py
  • tests/test_server_endpoints.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • holmes/core/transformers/llm_summarize.py
  • holmes/core/truncation/input_context_window_limiter.py
  • holmes/core/truncation/compaction.py

@mainred mainred changed the title feat: Migrate LLM calls from sync to async for improved concurrency WIP feat: Migrate LLM calls from sync to async for improved concurrency Mar 11, 2026
@mainred mainred changed the title WIP feat: Migrate LLM calls from sync to async for improved concurrency feat: Migrate LLM calls from sync to async for improved concurrency Mar 18, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
holmes/core/tool_calling_llm.py (1)

607-621: ⚠️ Potential issue | 🔴 Critical

The async migration left this file with critical syntax errors that prevent import and a blocking synchronous call in an async context.

  • Lines 611–621 (and 1116–1158) have unexpected indentation that breaks the syntax
  • Line 950 contains a bare yield event statement (should iterate over the events collection)
  • process_tool_decisions() is a synchronous method called without await from the async call_stream() function, which will block the event loop when processing tool approvals

The module will not import as written. Once syntax errors are fixed, approved-tool replays will still block the server event loop.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/tool_calling_llm.py` around lines 607 - 621, The file has
syntax/async issues: fix the bad indentation around the runbook refresh block
(the block that updates tool_number_offset, re-fetches tools via
self._get_tools(), logs the runbook activation, and logs the blank line after
tools_to_call) so indentation is consistent and the module parses; replace the
bare "yield event" (should iterate the events collection) with a proper loop
that yields each event from the events iterable; and make process_tool_decisions
non-blocking by either converting it to async (async def
process_tool_decisions(...)) and awaiting it from call_stream, or by running the
existing sync process_tool_decisions in an executor (e.g., loop.run_in_executor)
from call_stream so tool approval replay does not block the event loop—update
call_stream to await the async call or the run_in_executor future accordingly.
♻️ Duplicate comments (2)
server.py (1)

315-315: ⚠️ Potential issue | 🔴 Critical

Making chat() async also breaks the scheduled-prompts callback contract.

ScheduledPromptsExecutor still consumes chat_function(...) synchronously in its background thread. Passing chat here now returns a coroutine object instead of a ChatResponse, so that path will fail as soon as it reads response fields unless the executor starts awaiting or running the callback.

Verification script
#!/bin/bash
set -e
rg -n "async def chat|chat_function|self\.chat_function\(" \
  server.py \
  holmes/core/scheduled_prompts/executor.py -A6 -B2

Also applies to: 427-429

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server.py` at line 315, The chat handler was changed to an async def (chat)
but ScheduledPromptsExecutor expects a synchronous callback (it calls
self.chat_function(...) and reads response fields), so the executor will receive
a coroutine instead of a ChatResponse; either restore chat to a synchronous
function that returns ChatResponse (keeping signature chat(chat_request:
ChatRequest, http_request: Request) as a normal def) or modify
ScheduledPromptsExecutor to detect coroutine results from chat_function and
run/await them on an event loop (e.g., use asyncio.run / loop.run_until_complete
or schedule in an existing loop) before accessing response fields so that calls
to chat_function always yield a concrete ChatResponse. Ensure references to
chat, ChatRequest/ChatResponse, and ScheduledPromptsExecutor/chat_function are
updated accordingly.
holmes/core/transformers/llm_summarize.py (1)

154-156: ⚠️ Potential issue | 🔴 Critical

asyncio.run() here still blows up approved-tool replays.

transform() is synchronous, but approved tools are now replayed from async call_stream() before tools.py calls transform(). When that path reaches Line 154, Python raises RuntimeError: asyncio.run() cannot be called from a running event loop. Make the transformer path async-aware, or keep approved-tool replay on a worker thread instead of nesting an event loop here.

Verification script
#!/bin/bash
set -e
rg -n "def transform\(|asyncio.run\(|transformer_instance\.transform\(|def process_tool_decisions\(|async def call_stream" \
  holmes/core/transformers/llm_summarize.py \
  holmes/core/tools.py \
  holmes/core/tool_calling_llm.py -A6 -B2
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/transformers/llm_summarize.py` around lines 154 - 156, The
synchronous transform() calls asyncio.run(self._fast_llm.completion(...)) which
fails when an event loop is already running (approved-tool replay from async
call_stream()); refactor by adding an async variant (e.g., async def
transform_async(...)) that awaits self._fast_llm.completion(...) and move the
asyncio.run call out of the async path: keep transform() as a thin sync wrapper
that calls asyncio.run(transform_async(...)) for legacy synchronous callers, and
update async callers (call_stream() / tools.py where approved-tool replay
happens) to await transformer.transform_async(...) instead of calling
transformer.transform(), removing the direct asyncio.run() invocation inside
llm_summarize.py.
🤖 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 559-567: The parallel dispatch creates tasks passing the same
mutable previous_tool_calls (tool_calls) to each _invoke_llm_tool_call, allowing
race-through of prevent_overly_repeated_tool_call(); fix by
deduplicating/reserving tools before spawning tasks or performing the guard
serially: compute a deduped list or mark/reserve entries in tool_calls (the
previous_tool_calls structure) then spawn
asyncio.create_task(...asyncio.to_thread(self._invoke_llm_tool_call, ...)) only
for non-duplicate/reserved tools, or alternatively call
prevent_overly_repeated_tool_call() synchronously for each tool and skip
spawning threads for those that fail the guard (ensure changes touch the code
paths that build previous_tool_calls, the call-site that creates tasks with
asyncio.create_task/asyncio.to_thread, and the
_invoke_llm_tool_call/prevent_overly_repeated_tool_call functions).

In `@server.py`:
- Line 315: The streaming functions are trying to iterate a now-async generator
from ai.call_stream() with synchronous constructs; update stream_chat_formatter
to be async and replace its synchronous loop with an "async for message in
call_stream" loop (and await any awaits inside), and update
_stream_with_storage_cleanup to be async as well and use "async for" / "async
yield" (or "async for" collecting and then "yield") so they consume the async
generator correctly when called from async def chat; alternatively, if you
prefer to keep them sync, wrap ai.call_stream() with a synchronous bridge that
consumes the async generator and yields sync items before passing it in—apply
the change to the functions named stream_chat_formatter and
_stream_with_storage_cleanup and ensure chat passes the async generator to the
newly-async helpers.

---

Outside diff comments:
In `@holmes/core/tool_calling_llm.py`:
- Around line 607-621: The file has syntax/async issues: fix the bad indentation
around the runbook refresh block (the block that updates tool_number_offset,
re-fetches tools via self._get_tools(), logs the runbook activation, and logs
the blank line after tools_to_call) so indentation is consistent and the module
parses; replace the bare "yield event" (should iterate the events collection)
with a proper loop that yields each event from the events iterable; and make
process_tool_decisions non-blocking by either converting it to async (async def
process_tool_decisions(...)) and awaiting it from call_stream, or by running the
existing sync process_tool_decisions in an executor (e.g., loop.run_in_executor)
from call_stream so tool approval replay does not block the event loop—update
call_stream to await the async call or the run_in_executor future accordingly.

---

Duplicate comments:
In `@holmes/core/transformers/llm_summarize.py`:
- Around line 154-156: The synchronous transform() calls
asyncio.run(self._fast_llm.completion(...)) which fails when an event loop is
already running (approved-tool replay from async call_stream()); refactor by
adding an async variant (e.g., async def transform_async(...)) that awaits
self._fast_llm.completion(...) and move the asyncio.run call out of the async
path: keep transform() as a thin sync wrapper that calls
asyncio.run(transform_async(...)) for legacy synchronous callers, and update
async callers (call_stream() / tools.py where approved-tool replay happens) to
await transformer.transform_async(...) instead of calling
transformer.transform(), removing the direct asyncio.run() invocation inside
llm_summarize.py.

In `@server.py`:
- Line 315: The chat handler was changed to an async def (chat) but
ScheduledPromptsExecutor expects a synchronous callback (it calls
self.chat_function(...) and reads response fields), so the executor will receive
a coroutine instead of a ChatResponse; either restore chat to a synchronous
function that returns ChatResponse (keeping signature chat(chat_request:
ChatRequest, http_request: Request) as a normal def) or modify
ScheduledPromptsExecutor to detect coroutine results from chat_function and
run/await them on an event loop (e.g., use asyncio.run / loop.run_until_complete
or schedule in an existing loop) before accessing response fields so that calls
to chat_function always yield a concrete ChatResponse. Ensure references to
chat, ChatRequest/ChatResponse, and ScheduledPromptsExecutor/chat_function are
updated accordingly.
🪄 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: 74c11b23-efb1-42b1-a3cc-27088c2e32ae

📥 Commits

Reviewing files that changed from the base of the PR and between 093bcee and b320ed896eef6454f527570f89dcd9e4325299be.

📒 Files selected for processing (10)
  • holmes/core/llm.py
  • holmes/core/tool_calling_llm.py
  • holmes/core/transformers/llm_summarize.py
  • holmes/core/truncation/compaction.py
  • holmes/core/truncation/input_context_window_limiter.py
  • holmes/interactive.py
  • holmes/main.py
  • server.py
  • tests/core/transformers/test_llm_summarize.py
  • tests/test_server_endpoints.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • holmes/core/truncation/compaction.py
  • tests/test_server_endpoints.py
  • holmes/core/truncation/input_context_window_limiter.py
  • holmes/interactive.py

Comment thread holmes/core/tool_calling_llm.py Outdated
Comment on lines 559 to 567
task = asyncio.create_task(
asyncio.to_thread(
self._invoke_llm_tool_call,
tool_to_call=t,
previous_tool_calls=tool_calls,
trace_span=trace_span,
tool_number=tool_number,
request_context=request_context,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Parallel dispatch bypasses the duplicate-call safeguard.

Each worker gets the same previous_tool_calls=tool_calls reference before any sibling result is appended. If the model emits the same tool twice in one turn, both threads can clear prevent_overly_repeated_tool_call() and execute the external side effect twice. Reserve/dedupe the batch before spawning threads, or serialize just the guard check.

Also applies to: 1095-1104

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/tool_calling_llm.py` around lines 559 - 567, The parallel
dispatch creates tasks passing the same mutable previous_tool_calls (tool_calls)
to each _invoke_llm_tool_call, allowing race-through of
prevent_overly_repeated_tool_call(); fix by deduplicating/reserving tools before
spawning tasks or performing the guard serially: compute a deduped list or
mark/reserve entries in tool_calls (the previous_tool_calls structure) then
spawn asyncio.create_task(...asyncio.to_thread(self._invoke_llm_tool_call, ...))
only for non-duplicate/reserved tools, or alternatively call
prevent_overly_repeated_tool_call() synchronously for each tool and skip
spawning threads for those that fail the guard (ensure changes touch the code
paths that build previous_tool_calls, the call-site that creates tasks with
asyncio.create_task/asyncio.to_thread, and the
_invoke_llm_tool_call/prevent_overly_repeated_tool_call functions).

Comment thread server.py
Signed-off-by: Qingchuan Hao <qingchuan.hao@microsoft.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
tests/plugins/toolsets/grafana/test_grafana_tempo_tools.py (1)

558-560: ⚠️ Potential issue | 🟡 Minor

Move method-local imports out of test bodies.

Line 558, Line 589, and Line 669 import a module inside methods. Keep imports at module scope (or use string-based monkeypatch targets).

Suggested patch
-        import holmes.plugins.toolsets.grafana.toolset_grafana_tempo as tempo_module
-
-        monkeypatch.setattr(tempo_module, "MAX_GRAPH_POINTS", 100)
+        monkeypatch.setattr(
+            "holmes.plugins.toolsets.grafana.toolset_grafana_tempo.MAX_GRAPH_POINTS",
+            100,
+        )
@@
-        import holmes.plugins.toolsets.grafana.toolset_grafana_tempo as tempo_module
-
-        monkeypatch.setattr(tempo_module, "MAX_GRAPH_POINTS", 100)
+        monkeypatch.setattr(
+            "holmes.plugins.toolsets.grafana.toolset_grafana_tempo.MAX_GRAPH_POINTS",
+            100,
+        )
@@
-        import holmes.plugins.toolsets.grafana.toolset_grafana_tempo as tempo_module
-
-        monkeypatch.setattr(tempo_module, "MAX_GRAPH_POINTS", 100)
+        monkeypatch.setattr(
+            "holmes.plugins.toolsets.grafana.toolset_grafana_tempo.MAX_GRAPH_POINTS",
+            100,
+        )

As per coding guidelines, "Always place Python imports at the top of the file, not inside functions or methods."

Also applies to: 589-591, 669-671

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/plugins/toolsets/grafana/test_grafana_tempo_tools.py` around lines 558
- 560, Move the method-local imports of
holmes.plugins.toolsets.grafana.toolset_grafana_tempo out of the test functions
and into module scope (top of the test file) or change monkeypatch targets to
string-based references; specifically, remove inline imports that assign
tempo_module inside test bodies and instead import tempo_module once at module
level and keep monkeypatch.setattr(tempo_module, "MAX_GRAPH_POINTS", 100) (and
the similar uses around the other tests) so the tests reference the same
top-level tempo_module import rather than performing imports inside the
functions.
holmes/utils/pydantic_utils.py (1)

223-227: ⚠️ Potential issue | 🟡 Minor

Support PEP 604 unions when extracting nested BaseModel types.

Line 223 checks only origin is Union, which misses PEP 604 union syntax like NestedModel | None (which creates types.UnionType). This causes nested examples to degrade to "your_<field>" unexpectedly.

Add UnionType to the import from types and update the condition:

Proposed fix
+from types import UnionType
+from typing import (
     Annotated,
     Any,
     ClassVar,
     Dict,
     List,
     Optional,
     Tuple,
     Type,
     Union,
     get_args,
     get_origin,
 )

-    if origin is Union:
+    if origin in (Union, UnionType):
         args = [a for a in get_args(annotation) if a is not type(None)]  # noqa: E721
         if len(args) == 1:
             return _extract_base_model_subclass(args[0])
         return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/utils/pydantic_utils.py` around lines 223 - 227, The code in
_extract_base_model_subclass currently only detects typing.Union via "if origin
is Union:" and therefore misses PEP 604 unions (types.UnionType) like
"NestedModel | None"; update the check to handle both Union and UnionType
(import UnionType from types) and treat UnionType the same as Union by
extracting non-None args via get_args and continuing the existing logic (i.e.,
if len(args) == 1 return _extract_base_model_subclass(args[0]) else return
None). Ensure the new import for UnionType is added where other types are
imported so both union forms are recognized.
server.py (1)

394-411: ⚠️ Potential issue | 🟠 Major

Non-streaming path uses synchronous ai.call() inside async endpoint, blocking the event loop.

The chat() endpoint is async def (line 309), but the non-streaming path calls ai.call() synchronously at line 395 without await. The ai.call() method is a synchronous wrapper that internally drains an async generator, which will block the event loop and prevent concurrent request handling. The streaming path correctly uses the async ai.call_stream() at line 380.

Wrap the call in asyncio.to_thread() to run it on a thread pool executor, or refactor to use an async variant.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server.py` around lines 394 - 411, The non-streaming branch in async chat()
calls the blocking ai.call() (while ai.call_stream() is used for streaming),
which will block the event loop; run the blocking call off the event loop (e.g.,
wrap the ai.call(...) invocation in asyncio.to_thread(...) or refactor to an
async variant) and preserve the current finally cleanup (storage.__exit__(...))
and return construction (ChatResponse(...)); ensure you await the to_thread
result and keep using llm_call.result, llm_call.tool_calls, llm_call.messages,
and llm_call.metadata when building the response.
🧹 Nitpick comments (6)
holmes/core/scheduled_prompts/executor.py (1)

206-211: Consider using a persistent event loop for async chat_function calls in threaded context.

The current approach creates a new event loop via asyncio.run() for each scheduled prompt execution. Since _execute_prompt runs in a background thread (started at line 68), this works but has considerations:

  1. Overhead: Creates/destroys an event loop per prompt execution
  2. No sharing: Cannot reuse connections or resources across async calls

For a thread that processes multiple prompts over its lifetime, consider maintaining a persistent event loop:

♻️ Alternative: persistent loop in the executor thread
 class ScheduledPromptsExecutor:
     def __init__(
         self,
         dal: "SupabaseDal",
         config: "Config",
         chat_function: ChatFunction,
     ):
         self.dal = dal
         self.config = config
         self.chat_function = chat_function
         self.running = False
         self.thread: Optional[threading.Thread] = None
+        self._loop: Optional[asyncio.AbstractEventLoop] = None
         # ...
 
     def _run_loop(self):
+        self._loop = asyncio.new_event_loop()
+        asyncio.set_event_loop(self._loop)
         while self.running:
             # ...
+        self._loop.close()
+        self._loop = None

Then in _execute_prompt:

         result = self.chat_function(chat_request, empty_request)
         if asyncio.iscoroutine(result):
-            response = asyncio.run(result)
+            response = self._loop.run_until_complete(result)
         else:
             response = result
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/scheduled_prompts/executor.py` around lines 206 - 211, The use of
asyncio.run() inside _execute_prompt causes a new event loop per call and
prevents reusing async resources; instead create and retain a persistent event
loop for the background thread that runs prompts and execute chat_function
coroutines against it. Concretely: when the executor thread starts, create an
event loop (asyncio.new_event_loop()) and either run it in that thread
(loop.run_forever()) or keep it accessible; then in _execute_prompt detect
coroutines with asyncio.iscoroutine(result) but submit them via
asyncio.run_coroutine_threadsafe(result, loop) (or loop.call_soon_threadsafe
with appropriate wrapper) so you reuse the same loop and connections across
chat_function invocations and avoid repeatedly creating/destroying loops.
holmes/toolset_config_tui.py (1)

154-156: Unusual annotation style with parentheses wrapping a comment.

The field_type annotation uses parentheses to include an inline comment:

field_type: (
    str  # "str" | "int" | "float" | "bool" | "enum" | "dict" | "list" | "model"
)

This is syntactically valid Python (the parentheses wrap the type str with trailing comment), and the type is correctly str. However, this pattern is unconventional—typically a comment would go on a separate line or the type options would use Literal[...] for type safety.

💡 Consider using Literal type for self-documenting type safety
+from typing import Literal
+
+FieldTypeStr = Literal["str", "int", "float", "bool", "enum", "dict", "list", "model"]
+
 `@dataclass`
 class ConfigFieldNode:
     """One row in the config tree."""

     key: str
-    field_type: (
-        str  # "str" | "int" | "float" | "bool" | "enum" | "dict" | "list" | "model"
-    )
+    field_type: FieldTypeStr
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/toolset_config_tui.py` around lines 154 - 156, The annotation for
field_type currently wraps the type in parentheses with an inline comment;
replace this unconventional pattern by either (a) moving the explanatory comment
to the previous line and using a plain annotation field_type: str, or (b) for
stricter, self-documenting typing, change the annotation to use typing.Literal
(e.g. field_type:
Literal["str","int","float","bool","enum","dict","list","model"]) and import
Literal from typing; update any usages of field_type to match the chosen
annotation.
holmes/core/tool_calling_llm.py (2)

638-651: Use asyncio.get_running_loop() instead of deprecated asyncio.get_event_loop().

asyncio.get_event_loop() is deprecated in Python 3.10+ and will emit a DeprecationWarning in Python 3.12+ when called from a coroutine without a running event loop. Since this code is inside an async function, use asyncio.get_running_loop() instead.

♻️ Suggested fix
             if not tool_response:
                 # Run sync tool execution in a thread pool to avoid blocking the event loop
-                loop = asyncio.get_event_loop()
+                loop = asyncio.get_running_loop()
                 tool_response = await loop.run_in_executor(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/tool_calling_llm.py` around lines 638 - 651, Replace the
deprecated asyncio.get_event_loop() call with asyncio.get_running_loop() in the
async context where the thread-pool execution is started (the block that awaits
loop.run_in_executor and assigns tool_response); specifically update the code
that obtains loop before calling run_in_executor around the invocation of
self._directly_invoke_tool_call so it uses asyncio.get_running_loop() to avoid
deprecation warnings and ensure a running event loop is used.

330-347: Static analysis flags uninitialized variables messages and tool_number_offset.

Ruff reports that messages (line 338) and tool_number_offset (line 344) are referenced before assignment in the inner _drain() function. These variables are captured from the outer call() method's parameters, but the closure captures them correctly. However, the messages variable is reassigned at line 402 inside the while loop, which modifies the outer scope binding.

This is technically correct Python closure behavior, but consider using nonlocal messages to make the intent explicit and silence the linter warning.

♻️ Suggested fix to clarify closure intent
         async def _drain() -> LLMResult:
+            nonlocal messages
             all_tool_calls: list[dict] = []
             tool_decisions: Optional[List[ToolApprovalDecision]] = None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/tool_calling_llm.py` around lines 330 - 347, The inner async
function _drain() reassigns outer-scope variables (messages and possibly
tool_number_offset) which confuses static analysis; inside _drain(), add a
nonlocal declaration (e.g., nonlocal messages, tool_number_offset) before any
use or reassignment to make the closure intent explicit and silence the linter,
keeping existing uses like call_stream(..., msgs=messages,
tool_number_offset=tool_number_offset, ...) and the reassignment of messages
later in the loop unchanged.
tests/test_toolset_config_tui.py (1)

953-955: Unused variable msg from tuple unpacking.

The unpacked msg variable is never used. Prefix it with an underscore to indicate it's intentionally unused.

♻️ Suggested fix
-        ok, msg = save_config_to_file(
+        ok, _msg = save_config_to_file(
             config_file, "jira_server", config_dict, is_mcp=True
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_toolset_config_tui.py` around lines 953 - 955, The tuple returned
by save_config_to_file is being unpacked into ok and msg, but msg is never used;
update the unpacking in the test to use an underscore-prefixed name (e.g., _msg)
instead of msg to indicate an intentionally unused variable. Locate the call to
save_config_to_file in tests/test_toolset_config_tui.py (the line with ok, msg =
save_config_to_file(...)) and change the second target to _msg so the unused
variable warning is resolved.
experimental/ag-ui/server-agui.py (1)

85-86: Endpoint agui_chat is synchronous but contains async generator.

The agui_chat endpoint (line 86) is defined as a regular def, but it returns a StreamingResponse wrapping an async generator event_generator. While FastAPI can handle this, the internal async generator calls ai.call_stream() which is now an async generator.

Consider making agui_chat an async def for consistency with the async patterns adopted elsewhere in this PR.

♻️ Suggested fix
 `@app.post`("/api/agui/chat")
-def agui_chat(input_data: RunAgentInput, request: Request):
+async def agui_chat(input_data: RunAgentInput, request: Request):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@experimental/ag-ui/server-agui.py` around lines 85 - 86, The agui_chat
endpoint is currently a synchronous def but relies on an async generator
(event_generator) that calls the async generator ai.call_stream and is returned
inside a StreamingResponse; change agui_chat to async def so it consistently
runs in the event loop, and ensure event_generator is defined as async def and
yields from ai.call_stream correctly (no blocking calls or awaits at the
top-level of the endpoint), then return StreamingResponse(event_generator(),
media_type="text/event-stream") as before.
🤖 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/llm.py`:
- Around line 689-709: The async method acompletion currently calls
litellm.acompletion() directly, so async LLM calls are not traced; update this
by using the tracer-wrapped LLM just like completion() does (i.e., call
self.tracer.wrap_llm(self.litellm) or the equivalent wrapped instance before
invoking acompletion), and if needed enhance the tracer implementation
(WrappedLiteLLM / ChatCompletionWrapper) to provide proper async support by
implementing/forwarding acompletion (or initializing ChatCompletionWrapper with
acreate_fn) so the wrapped object actually supports async operations and the
tracer observes async calls.
- Around line 237-258: The acompletion method uses a mutable default for tools
(tools: Optional[List[Dict[str, Any]]] = []); change the signature to use None
(tools: Optional[List[Dict[str, Any]]] = None) and inside acompletion set tools
= tools or [] (or list(tools) if you want a copy) before calling
self.completion, so you never share a single list instance; update any related
parameter handling passed to self.completion to use this local initialized list
and keep tool_choice/response_format/temperature/drop_params/stream unchanged.

In `@holmes/plugins/toolsets/mongodb/mongodb.py`:
- Around line 35-37: The JSONDecodeError caught in the except block that
constructs the ValueError for invalid JSON should preserve the original
exception context by using exception chaining; update the raise in that block to
re-raise ValueError(...) from e (referencing the local variables param_name and
value in the same except block where json.JSONDecodeError is handled) so the
original traceback is kept.

In `@tests/plugins/toolsets/grafana/test_grafana_tempo_tools.py`:
- Around line 554-556: Update the test function signatures to include explicit
type hints for fixtures and a return annotation -> None; specifically add typing
to test_metrics_range_with_no_step_auto_calculates(tempo_toolset, monkeypatch)
and the other two affected tests (the ones starting at the places you noted) so
parameters are typed (e.g., tempo_toolset: TempoToolset and monkeypatch:
pytest.MonkeyPatch or appropriate fixture types used in the repo) and append ->
None; also add necessary imports for those types (from pytest and your test
fixtures module or typing) so mypy passes.

---

Outside diff comments:
In `@holmes/utils/pydantic_utils.py`:
- Around line 223-227: The code in _extract_base_model_subclass currently only
detects typing.Union via "if origin is Union:" and therefore misses PEP 604
unions (types.UnionType) like "NestedModel | None"; update the check to handle
both Union and UnionType (import UnionType from types) and treat UnionType the
same as Union by extracting non-None args via get_args and continuing the
existing logic (i.e., if len(args) == 1 return
_extract_base_model_subclass(args[0]) else return None). Ensure the new import
for UnionType is added where other types are imported so both union forms are
recognized.

In `@server.py`:
- Around line 394-411: The non-streaming branch in async chat() calls the
blocking ai.call() (while ai.call_stream() is used for streaming), which will
block the event loop; run the blocking call off the event loop (e.g., wrap the
ai.call(...) invocation in asyncio.to_thread(...) or refactor to an async
variant) and preserve the current finally cleanup (storage.__exit__(...)) and
return construction (ChatResponse(...)); ensure you await the to_thread result
and keep using llm_call.result, llm_call.tool_calls, llm_call.messages, and
llm_call.metadata when building the response.

In `@tests/plugins/toolsets/grafana/test_grafana_tempo_tools.py`:
- Around line 558-560: Move the method-local imports of
holmes.plugins.toolsets.grafana.toolset_grafana_tempo out of the test functions
and into module scope (top of the test file) or change monkeypatch targets to
string-based references; specifically, remove inline imports that assign
tempo_module inside test bodies and instead import tempo_module once at module
level and keep monkeypatch.setattr(tempo_module, "MAX_GRAPH_POINTS", 100) (and
the similar uses around the other tests) so the tests reference the same
top-level tempo_module import rather than performing imports inside the
functions.

---

Nitpick comments:
In `@experimental/ag-ui/server-agui.py`:
- Around line 85-86: The agui_chat endpoint is currently a synchronous def but
relies on an async generator (event_generator) that calls the async generator
ai.call_stream and is returned inside a StreamingResponse; change agui_chat to
async def so it consistently runs in the event loop, and ensure event_generator
is defined as async def and yields from ai.call_stream correctly (no blocking
calls or awaits at the top-level of the endpoint), then return
StreamingResponse(event_generator(), media_type="text/event-stream") as before.

In `@holmes/core/scheduled_prompts/executor.py`:
- Around line 206-211: The use of asyncio.run() inside _execute_prompt causes a
new event loop per call and prevents reusing async resources; instead create and
retain a persistent event loop for the background thread that runs prompts and
execute chat_function coroutines against it. Concretely: when the executor
thread starts, create an event loop (asyncio.new_event_loop()) and either run it
in that thread (loop.run_forever()) or keep it accessible; then in
_execute_prompt detect coroutines with asyncio.iscoroutine(result) but submit
them via asyncio.run_coroutine_threadsafe(result, loop) (or
loop.call_soon_threadsafe with appropriate wrapper) so you reuse the same loop
and connections across chat_function invocations and avoid repeatedly
creating/destroying loops.

In `@holmes/core/tool_calling_llm.py`:
- Around line 638-651: Replace the deprecated asyncio.get_event_loop() call with
asyncio.get_running_loop() in the async context where the thread-pool execution
is started (the block that awaits loop.run_in_executor and assigns
tool_response); specifically update the code that obtains loop before calling
run_in_executor around the invocation of self._directly_invoke_tool_call so it
uses asyncio.get_running_loop() to avoid deprecation warnings and ensure a
running event loop is used.
- Around line 330-347: The inner async function _drain() reassigns outer-scope
variables (messages and possibly tool_number_offset) which confuses static
analysis; inside _drain(), add a nonlocal declaration (e.g., nonlocal messages,
tool_number_offset) before any use or reassignment to make the closure intent
explicit and silence the linter, keeping existing uses like call_stream(...,
msgs=messages, tool_number_offset=tool_number_offset, ...) and the reassignment
of messages later in the loop unchanged.

In `@holmes/toolset_config_tui.py`:
- Around line 154-156: The annotation for field_type currently wraps the type in
parentheses with an inline comment; replace this unconventional pattern by
either (a) moving the explanatory comment to the previous line and using a plain
annotation field_type: str, or (b) for stricter, self-documenting typing, change
the annotation to use typing.Literal (e.g. field_type:
Literal["str","int","float","bool","enum","dict","list","model"]) and import
Literal from typing; update any usages of field_type to match the chosen
annotation.

In `@tests/test_toolset_config_tui.py`:
- Around line 953-955: The tuple returned by save_config_to_file is being
unpacked into ok and msg, but msg is never used; update the unpacking in the
test to use an underscore-prefixed name (e.g., _msg) instead of msg to indicate
an intentionally unused variable. Locate the call to save_config_to_file in
tests/test_toolset_config_tui.py (the line with ok, msg =
save_config_to_file(...)) and change the second target to _msg so the unused
variable warning is resolved.
🪄 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: d753070a-97b6-4ba1-929a-b95a36d875f8

📥 Commits

Reviewing files that changed from the base of the PR and between b320ed896eef6454f527570f89dcd9e4325299be and a5e0d29.

📒 Files selected for processing (121)
  • .claude/skills/create-eval/SKILL.md
  • .claude/skills/create-eval/references/anti-hallucination.md
  • .claude/skills/create-eval/references/test-case-format.md
  • conftest.py
  • docs/data-sources/builtin-toolsets/splunk-mcp.md
  • docs/development/evaluations/history/frontier_5_models_20260314_204516.md
  • docs/development/evaluations/history/results_20260122_074259.md
  • docs/development/evaluations/history/results_20260127_161120.md
  • docs/development/evaluations/history/results_20260129_094857.md
  • docs/development/evaluations/history/results_20260311_210836.md
  • docs/development/evaluations/history/results_20260315_041151.md
  • docs/development/evaluations/model-comparison-summary-20260315.md
  • examples/custom_llm.py
  • experimental/ag-ui/server-agui.py
  • holmes/checks/__init__.py
  • holmes/checks/checks_api.py
  • holmes/common/cli_commons.py
  • holmes/config.py
  • holmes/core/azure_token.py
  • holmes/core/json_schema_coerce.py
  • holmes/core/llm.py
  • holmes/core/llm_usage.py
  • holmes/core/openai_formatting.py
  • holmes/core/scheduled_prompts/executor.py
  • holmes/core/tool_calling_llm.py
  • holmes/core/tools.py
  • holmes/core/tools_utils/tool_context_window_limiter.py
  • holmes/core/tools_utils/tool_executor.py
  • holmes/core/truncation/compaction.py
  • holmes/core/truncation/input_context_window_limiter.py
  • holmes/interactive.py
  • holmes/main.py
  • holmes/plugins/toolsets/__init__.py
  • holmes/plugins/toolsets/bash/validation.py
  • holmes/plugins/toolsets/confluence/confluence.py
  • holmes/plugins/toolsets/coralogix/utils.py
  • holmes/plugins/toolsets/database/database.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_general.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_logs.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_traces.py
  • holmes/plugins/toolsets/elasticsearch/elasticsearch.py
  • holmes/plugins/toolsets/grafana/base_grafana_toolset.py
  • holmes/plugins/toolsets/grafana/common.py
  • holmes/plugins/toolsets/grafana/toolset_grafana.py
  • holmes/plugins/toolsets/http/http_toolset.py
  • holmes/plugins/toolsets/inspektor_gadget.yaml
  • holmes/plugins/toolsets/internet/internet.py
  • holmes/plugins/toolsets/mcp/toolset_mcp.py
  • holmes/plugins/toolsets/mongodb/mongodb.py
  • holmes/plugins/toolsets/prometheus/prometheus.py
  • holmes/plugins/toolsets/servicenow_tables/servicenow_tables.py
  • holmes/toolset_config_tui.py
  • holmes/utils/holmes_status.py
  • holmes/utils/holmes_sync_toolsets.py
  • holmes/utils/pydantic_utils.py
  • holmes/utils/stream.py
  • holmes_operator/scheduler/job_executor.py
  • holmes_operator/scheduler/manager.py
  • scripts/cli_performance_benchmark.py
  • server.py
  • tests/checks/test_checks_api.py
  • tests/checks/test_checks_cli.py
  • tests/config_class/conftest.py
  • tests/config_class/test_config_load_cloud_mcp.py
  • tests/config_class/test_source_factory.py
  • tests/core/test_image_token_counting.py
  • tests/core/test_llm_api_base_version.py
  • tests/core/test_prompt.py
  • tests/core/test_tool_memory_limit.py
  • tests/core/test_tool_output_deduplication.py
  • tests/integration/test_kubernetes_transformer_execution.py
  • tests/llm/conftest.py
  • tests/llm/fixtures/test_ask_holmes/212_large_configmap_needle/generate_configs.py
  • tests/llm/fixtures/test_ask_holmes/231_confluence_large_page_eval/generate_large_page.py
  • tests/llm/fixtures/test_ask_holmes/50a_logs_since_last_specific_month/test_case.yaml
  • tests/llm/fixtures/test_ask_holmes/65_health_check_followup/test_case.yaml
  • tests/llm/test_ask_holmes.py
  • tests/llm/test_holmes_checks.py
  • tests/llm/utils/braintrust.py
  • tests/llm/utils/braintrust_history.py
  • tests/llm/utils/classifiers.py
  • tests/llm/utils/mock_dal.py
  • tests/llm/utils/property_manager.py
  • tests/llm/utils/reporting/github_reporter.py
  • tests/llm/utils/reporting/terminal_reporter.py
  • tests/llm/utils/test_case_utils.py
  • tests/llm/utils/test_toolset.py
  • tests/mocks/toolset_mocks.py
  • tests/plugins/runbooks/test_catalog.py
  • tests/plugins/sources/test_pagerduty_source.py
  • tests/plugins/toolsets/database/test_database.py
  • tests/plugins/toolsets/datadog/logs/test_check_prerequisites.py
  • tests/plugins/toolsets/datadog/metrics/test_datadog_metrics_live.py
  • tests/plugins/toolsets/datadog/traces/test_datadog_traces_live.py
  • tests/plugins/toolsets/grafana/test_grafana_tempo_api.py
  • tests/plugins/toolsets/grafana/test_grafana_tempo_tools.py
  • tests/plugins/toolsets/grafana/test_grafana_tempo_unit.py
  • tests/plugins/toolsets/http/test_http_toolset.py
  • tests/plugins/toolsets/test_confluence_tools.py
  • tests/plugins/toolsets/test_elasticsearch_mtls.py
  • tests/plugins/toolsets/test_kubernetes_transformers.py
  • tests/plugins/toolsets/test_logging_api.py
  • tests/plugins/toolsets/test_runbook.py
  • tests/test_approval_workflow.py
  • tests/test_bash_toolset_validation.py
  • tests/test_cache.py
  • tests/test_check_prerequisites.py
  • tests/test_database_toolset.py
  • tests/test_format_tags.py
  • tests/test_header_propagation.py
  • tests/test_http_docs.py
  • tests/test_json_schema_coerce.py
  • tests/test_mcp_refresh_backoff.py
  • tests/test_mcp_toolset.py
  • tests/test_openai_formatting.py
  • tests/test_server_endpoints.py
  • tests/test_tool_calling_llm.py
  • tests/test_toolset_auto_enable.py
  • tests/test_toolset_config_tui.py
  • tests/utils/test_pydantic_utils.py
💤 Files with no reviewable changes (12)
  • docs/data-sources/builtin-toolsets/splunk-mcp.md
  • tests/core/test_tool_output_deduplication.py
  • holmes/plugins/toolsets/inspektor_gadget.yaml
  • holmes/plugins/toolsets/datadog/toolset_datadog_logs.py
  • tests/utils/test_pydantic_utils.py
  • holmes/config.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_traces.py
  • holmes_operator/scheduler/manager.py
  • holmes/common/cli_commons.py
  • tests/plugins/runbooks/test_catalog.py
  • tests/plugins/toolsets/test_logging_api.py
  • tests/llm/utils/classifiers.py
✅ Files skipped from review due to trivial changes (58)
  • docs/development/evaluations/history/frontier_5_models_20260314_204516.md
  • tests/plugins/toolsets/datadog/logs/test_check_prerequisites.py
  • docs/development/evaluations/model-comparison-summary-20260315.md
  • scripts/cli_performance_benchmark.py
  • tests/test_format_tags.py
  • tests/llm/utils/reporting/terminal_reporter.py
  • holmes/core/json_schema_coerce.py
  • holmes/plugins/toolsets/mcp/toolset_mcp.py
  • tests/plugins/toolsets/grafana/test_grafana_tempo_api.py
  • holmes/core/tools_utils/tool_executor.py
  • tests/core/test_tool_memory_limit.py
  • tests/test_mcp_toolset.py
  • tests/llm/fixtures/test_ask_holmes/50a_logs_since_last_specific_month/test_case.yaml
  • tests/plugins/sources/test_pagerduty_source.py
  • docs/development/evaluations/history/results_20260127_161120.md
  • docs/development/evaluations/history/results_20260122_074259.md
  • docs/development/evaluations/history/results_20260311_210836.md
  • holmes/plugins/toolsets/datadog/toolset_datadog_general.py
  • tests/llm/utils/property_manager.py
  • holmes/plugins/toolsets/bash/validation.py
  • .claude/skills/create-eval/SKILL.md
  • tests/plugins/toolsets/datadog/traces/test_datadog_traces_live.py
  • holmes/core/tools_utils/tool_context_window_limiter.py
  • holmes/plugins/toolsets/grafana/toolset_grafana.py
  • tests/llm/conftest.py
  • holmes_operator/scheduler/job_executor.py
  • holmes/core/llm_usage.py
  • tests/plugins/toolsets/datadog/metrics/test_datadog_metrics_live.py
  • tests/plugins/toolsets/test_elasticsearch_mtls.py
  • tests/test_http_docs.py
  • tests/llm/utils/test_case_utils.py
  • tests/plugins/toolsets/test_runbook.py
  • docs/development/evaluations/history/results_20260129_094857.md
  • examples/custom_llm.py
  • holmes/core/openai_formatting.py
  • tests/llm/utils/braintrust.py
  • holmes/plugins/toolsets/prometheus/prometheus.py
  • docs/development/evaluations/history/results_20260315_041151.md
  • .claude/skills/create-eval/references/test-case-format.md
  • tests/llm/test_ask_holmes.py
  • holmes/plugins/toolsets/servicenow_tables/servicenow_tables.py
  • tests/llm/utils/reporting/github_reporter.py
  • tests/llm/fixtures/test_ask_holmes/231_confluence_large_page_eval/generate_large_page.py
  • holmes/plugins/toolsets/coralogix/utils.py
  • tests/llm/fixtures/test_ask_holmes/212_large_configmap_needle/generate_configs.py
  • tests/test_openai_formatting.py
  • holmes/core/tools.py
  • tests/plugins/toolsets/grafana/test_grafana_tempo_unit.py
  • tests/core/test_llm_api_base_version.py
  • holmes/plugins/toolsets/confluence/confluence.py
  • tests/llm/fixtures/test_ask_holmes/65_health_check_followup/test_case.yaml
  • holmes/plugins/toolsets/init.py
  • tests/integration/test_kubernetes_transformer_execution.py
  • tests/config_class/test_config_load_cloud_mcp.py
  • holmes/plugins/toolsets/http/http_toolset.py
  • holmes/plugins/toolsets/database/database.py
  • tests/test_tool_calling_llm.py
  • .claude/skills/create-eval/references/anti-hallucination.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • tests/test_server_endpoints.py
  • holmes/interactive.py
  • holmes/main.py
  • holmes/core/truncation/input_context_window_limiter.py

Comment thread holmes/core/llm.py
Comment on lines +237 to +258
async def acompletion(
self,
messages: List[Dict[str, Any]],
tools: Optional[List[Dict[str, Any]]] = [],
tool_choice: Optional[Union[str, dict]] = None,
response_format: Optional[Union[dict, Type[BaseModel]]] = None,
temperature: Optional[float] = None,
drop_params: Optional[bool] = None,
stream: Optional[bool] = None,
) -> Union[ModelResponse, CustomStreamWrapper]:
"""Async version of completion. Subclasses may override for true async.
Default implementation falls back to sync completion."""
return self.completion(
messages=messages,
tools=tools,
tool_choice=tool_choice,
response_format=response_format,
temperature=temperature,
drop_params=drop_params,
stream=stream,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Mutable default argument for tools parameter.

Using a mutable default ([]) can lead to unexpected behavior if the list is accidentally mutated. Use None and initialize inside the method.

🛠️ Suggested fix
     async def acompletion(
         self,
         messages: List[Dict[str, Any]],
-        tools: Optional[List[Dict[str, Any]]] = [],
+        tools: Optional[List[Dict[str, Any]]] = None,
         tool_choice: Optional[Union[str, dict]] = None,
         response_format: Optional[Union[dict, Type[BaseModel]]] = None,
         temperature: Optional[float] = None,
         drop_params: Optional[bool] = None,
         stream: Optional[bool] = None,
     ) -> Union[ModelResponse, CustomStreamWrapper]:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def acompletion(
self,
messages: List[Dict[str, Any]],
tools: Optional[List[Dict[str, Any]]] = [],
tool_choice: Optional[Union[str, dict]] = None,
response_format: Optional[Union[dict, Type[BaseModel]]] = None,
temperature: Optional[float] = None,
drop_params: Optional[bool] = None,
stream: Optional[bool] = None,
) -> Union[ModelResponse, CustomStreamWrapper]:
"""Async version of completion. Subclasses may override for true async.
Default implementation falls back to sync completion."""
return self.completion(
messages=messages,
tools=tools,
tool_choice=tool_choice,
response_format=response_format,
temperature=temperature,
drop_params=drop_params,
stream=stream,
)
async def acompletion(
self,
messages: List[Dict[str, Any]],
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Union[str, dict]] = None,
response_format: Optional[Union[dict, Type[BaseModel]]] = None,
temperature: Optional[float] = None,
drop_params: Optional[bool] = None,
stream: Optional[bool] = None,
) -> Union[ModelResponse, CustomStreamWrapper]:
"""Async version of completion. Subclasses may override for true async.
Default implementation falls back to sync completion."""
return self.completion(
messages=messages,
tools=tools,
tool_choice=tool_choice,
response_format=response_format,
temperature=temperature,
drop_params=drop_params,
stream=stream,
)
🧰 Tools
🪛 Ruff (0.15.6)

[warning] 240-240: Do not use mutable data structures for argument defaults

Replace with None; initialize within function

(B006)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/llm.py` around lines 237 - 258, The acompletion method uses a
mutable default for tools (tools: Optional[List[Dict[str, Any]]] = []); change
the signature to use None (tools: Optional[List[Dict[str, Any]]] = None) and
inside acompletion set tools = tools or [] (or list(tools) if you want a copy)
before calling self.completion, so you never share a single list instance;
update any related parameter handling passed to self.completion to use this
local initialized list and keep
tool_choice/response_format/temperature/drop_params/stream unchanged.

Comment thread holmes/core/llm.py
Comment on lines +689 to +709
async def acompletion(
self,
messages: List[Dict[str, Any]],
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Union[str, dict]] = None,
response_format: Optional[Union[dict, Type[BaseModel]]] = None,
temperature: Optional[float] = None,
drop_params: Optional[bool] = None,
stream: Optional[bool] = None,
) -> Union[ModelResponse, CustomStreamWrapper]:
kwargs = self._prepare_completion_kwargs(
messages,
tools,
tool_choice,
response_format,
temperature,
drop_params,
stream,
)
result = await litellm.acompletion(**kwargs)
return self._validate_completion_result(result)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify if the tracer wrapper supports async methods
rg -n "wrap_llm|acompletion" holmes/core/tracing.py -A5 -B2

Repository: HolmesGPT/holmesgpt

Length of output: 709


🏁 Script executed:

#!/bin/bash
# Check the completion() method around line 685 to verify the tracer wrapping claim
sed -n '670,690p' holmes/core/llm.py | cat -n

Repository: HolmesGPT/holmesgpt

Length of output: 976


🏁 Script executed:

#!/bin/bash
# Get more context on BraintrustTracer.wrap_llm and ChatCompletionWrapper
sed -n '252,280p' holmes/core/tracing.py | cat -n

Repository: HolmesGPT/holmesgpt

Length of output: 1306


🏁 Script executed:

#!/bin/bash
# Check if ChatCompletionWrapper supports async methods
rg -n "ChatCompletionWrapper|acompletion" holmes/core/tracing.py -A10

Repository: HolmesGPT/holmesgpt

Length of output: 699


🏁 Script executed:

#!/bin/bash
# Check if WrappedLiteLLM has any acompletion method or if there's async tracer support elsewhere
rg -n "acompletion|acreate_fn" holmes/core/tracing.py

Repository: HolmesGPT/holmesgpt

Length of output: 103


Async completion calls lack tracer support.

The synchronous completion() method wraps litellm with the tracer (line 685), but acompletion() calls litellm.acompletion() directly without tracer wrapping. Async LLM calls won't be observed, creating observability gaps.

The tracer's wrap_llm() creates a WrappedLiteLLM wrapper that only overrides completion() and relies on __getattr__ for other methods. The underlying ChatCompletionWrapper is initialized with acreate_fn=None, meaning it has no async support. To fix this, the tracer implementation needs to be enhanced to support async operations, not just the call site.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/llm.py` around lines 689 - 709, The async method acompletion
currently calls litellm.acompletion() directly, so async LLM calls are not
traced; update this by using the tracer-wrapped LLM just like completion() does
(i.e., call self.tracer.wrap_llm(self.litellm) or the equivalent wrapped
instance before invoking acompletion), and if needed enhance the tracer
implementation (WrappedLiteLLM / ChatCompletionWrapper) to provide proper async
support by implementing/forwarding acompletion (or initializing
ChatCompletionWrapper with acreate_fn) so the wrapped object actually supports
async operations and the tracer observes async calls.

Comment on lines +35 to +37
raise ValueError(
f"Invalid JSON for '{param_name}': {e}. Received: {value[:200]}"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify Ruff B904 for explicit exception chaining in this file.
ruff check --select B904 holmes/plugins/toolsets/mongodb/mongodb.py

# Show the relevant block for quick inspection.
rg -n -A4 -B2 'except json\.JSONDecodeError as e:' holmes/plugins/toolsets/mongodb/mongodb.py

Repository: HolmesGPT/holmesgpt

Length of output: 722


Add exception chaining to preserve the original error context.

The json.JSONDecodeError is caught at line 34 but re-raised as ValueError without exception chaining, which loses traceback context and triggers Ruff B904.

🔧 Proposed fix
     except json.JSONDecodeError as e:
         raise ValueError(
             f"Invalid JSON for '{param_name}': {e}. Received: {value[:200]}"
-        )
+        ) from e
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
raise ValueError(
f"Invalid JSON for '{param_name}': {e}. Received: {value[:200]}"
)
except json.JSONDecodeError as e:
raise ValueError(
f"Invalid JSON for '{param_name}': {e}. Received: {value[:200]}"
) from e
🧰 Tools
🪛 Ruff (0.15.6)

[warning] 35-37: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/plugins/toolsets/mongodb/mongodb.py` around lines 35 - 37, The
JSONDecodeError caught in the except block that constructs the ValueError for
invalid JSON should preserve the original exception context by using exception
chaining; update the raise in that block to re-raise ValueError(...) from e
(referencing the local variables param_name and value in the same except block
where json.JSONDecodeError is handled) so the original traceback is kept.

Comment on lines +554 to +556
def test_metrics_range_with_no_step_auto_calculates(
self, tempo_toolset, monkeypatch
):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add type hints to the updated test method signatures.

The touched test methods still use untyped parameters and no return annotations; please add explicit fixture types and -> None.

Suggested patch
-    def test_metrics_range_with_no_step_auto_calculates(
-        self, tempo_toolset, monkeypatch
-    ):
+    def test_metrics_range_with_no_step_auto_calculates(
+        self,
+        tempo_toolset: GrafanaTempoToolset,
+        monkeypatch: pytest.MonkeyPatch,
+    ) -> None:
@@
-    def test_metrics_range_with_small_step_gets_adjusted(
-        self, tempo_toolset, monkeypatch
-    ):
+    def test_metrics_range_with_small_step_gets_adjusted(
+        self,
+        tempo_toolset: GrafanaTempoToolset,
+        monkeypatch: pytest.MonkeyPatch,
+    ) -> None:
@@
-    def test_metrics_range_step_adjustment_various_ranges(
-        self, tempo_toolset, monkeypatch
-    ):
+    def test_metrics_range_step_adjustment_various_ranges(
+        self,
+        tempo_toolset: GrafanaTempoToolset,
+        monkeypatch: pytest.MonkeyPatch,
+    ) -> None:

As per coding guidelines, "Type hints required in Python files (mypy configuration in pyproject.toml)."

Also applies to: 585-587, 665-667

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/plugins/toolsets/grafana/test_grafana_tempo_tools.py` around lines 554
- 556, Update the test function signatures to include explicit type hints for
fixtures and a return annotation -> None; specifically add typing to
test_metrics_range_with_no_step_auto_calculates(tempo_toolset, monkeypatch) and
the other two affected tests (the ones starting at the places you noted) so
parameters are typed (e.g., tempo_toolset: TempoToolset and monkeypatch:
pytest.MonkeyPatch or appropriate fixture types used in the repo) and append ->
None; also add necessary imports for those types (from pytest and your test
fixtures module or typing) so mypy passes.

@mainred mainred closed this May 19, 2026
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.

2 participants