fix: async-tool coroutine leak, async/sync tool-loop parity, hook-registry races - #4634
Conversation
…istry races (fixes #4633) Gap 1: the sync OpenAI-native tool path (_execute_tool_impl) never awaited async tool functions, handing the model a bare coroutine object instead of the real result. Await any coroutine returned by BaseTool.run / LangChain .run / CrewAI ._run / plain functions via the existing _run_async_in_sync_context bridge. Gap 2a: get_response_async now accepts and honours parallel_tool_calls, dispatching independent tool calls concurrently via asyncio.gather (mirroring the sync create_tool_call_executor(parallel=True)) while preserving call order for message building. Previously the parameter was silently absorbed by **kwargs. Gap 2b: the async tool-resolution path (execute_tool_async) now shares MCP resolution and hallucinated-name self-repair with the sync path via new _resolve_mcp_tool_result / _self_repair_tool_name helpers, and returns the same corrective, model-readable error (available tools + closest match) on a miss instead of a bare "not found". Gap 3b: HookRegistry now guards register/unregister/clear/enable/disable and snapshots the hook list in get_hooks under an RLock, eliminating the "list changed size during iteration" race on the process-wide default registry. Gap 3a (per-agent display-callback registry) intentionally deferred: a proper fix requires threading agent identity through the public register_display_callback API and every display callsite, expanding surface area against the lightweight-core mandate; the existing "first mode wins" guard already mitigates the primary clobber.
|
@coderabbitai review |
|
/review |
|
|
Warning Review limit reachedNext included review available in 35 minutes. View limit detailsLimit details: You’ve used all 8 included reviews currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (4)
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. Comment |
Greptile SummaryThe PR aligns synchronous and asynchronous tool execution, adds optional parallel async tool dispatch, and protects hook-registry mutation.
Confidence Score: 4/5The PR is not yet safe to merge because async MCP failures can still bypass circuit-breaker and loop-guard protection. The MCP branch returns at line 1734 before the circuit-breaker and loop-guard checks and recording paths, leaving the previously reported repeated-failure behavior outstanding. Files Needing Attention: src/praisonai-agents/praisonaiagents/agent/execution_mixin.py
|
| Filename | Overview |
|---|---|
| src/praisonai-agents/praisonaiagents/agent/execution_mixin.py | Adds async MCP resolution and tool-name repair, but MCP calls still return before the newly added repeated-call safeguards. |
| src/praisonai-agents/praisonaiagents/agent/tool_execution.py | Awaits coroutine tool results and extracts shared MCP normalization, resolution, and name-repair helpers. |
| src/praisonai-agents/praisonaiagents/hooks/registry.py | Serializes hook-list mutation and snapshots lookup results under an RLock. |
| src/praisonai-agents/praisonaiagents/llm/llm.py | Adds optional concurrent async tool dispatch while preserving tool-call result ordering. |
Reviews (2): Last reviewed commit: "fix: async MCP timeout normalization par..." | Re-trigger Greptile
| if inspect.isawaitable(mcp_result): | ||
| mcp_result = await mcp_result | ||
| return mcp_result |
There was a problem hiding this comment.
Async MCP safeguards are bypassed
When an async MCP tool times out or is repeatedly invoked, this early return treats MCP timeout strings as successful results and skips the circuit breaker and loop guard, causing timeouts to reach the model without retry classification while persistent failures continue without the sync path's protections.
Knowledge Base Used:
|
@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Review changes in this PR. Python SDK: praisonaiagents, praisonai. TypeScript SDK: src/praisonai-ts/. Do NOT modify src/praisonai-rust. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding. MANDATORY READ (before reviewing):
Phase 1: Review per AGENTS.md
MANDATORY COMMENT FORMAT — include this Phase 1 table in your review comment: Phase 1 — AGENTS.md review
For TypeScript PRs (src/praisonai-ts/), also add: Phase 2: FIX Valid Issues Phase 3: Final Verdict |
|
Claude finished @MervinPraison's task —— View job Final Architecture Review — PR #4634Reviewed against Phase 1 — AGENTS.md review
Phase 2 — Issues fixed & pushed (commit
|
Code Review by Qodo
1. MCP bypasses async safety controls
|
| found, mcp_result = resolve_mcp(function_name, arguments) | ||
| if found: | ||
| if inspect.isawaitable(mcp_result): | ||
| mcp_result = await mcp_result |
There was a problem hiding this comment.
1. Async mcp blocks event loop 📘 Rule violation ➹ Performance
The async MCP execution path invokes _resolve_mcp_tool_result synchronously on the event-loop thread, where MCP transport wrappers can block on future.result(), initialization waits, or response queues until completion or timeout. A slow achat() MCP operation can therefore stall every coroutine sharing the loop, unlike regular synchronous tools that are dispatched through an executor.
Agent Prompt
## Issue description
The async MCP resolution path invokes synchronous, blocking MCP transport wrappers on the event-loop thread before checking whether the returned result is awaitable. This can stall unrelated async work until the MCP operation completes or times out.
## Issue Context
SSE, HTTP-stream, and WebSocket wrappers block on `future.result()`, while the stdio runner waits synchronously for initialization and tool responses through a standard queue. Determine whether the selected MCP transport is synchronous before invoking it, then either add a genuinely asynchronous MCP dispatch path or offload unavoidable blocking calls with `asyncio.to_thread`; continue to support and await awaitable transport results after resolution, consistent with the existing native async invocation path that offloads synchronous callables.
## Fix Focus Areas
- src/praisonai-agents/praisonaiagents/agent/execution_mixin.py[1719-1726]
- src/praisonai-agents/praisonaiagents/agent/tool_execution.py[2899-2931]
- src/praisonai-agents/praisonaiagents/mcp/mcp.py[175-212]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| if inspect.isawaitable(mcp_result): | ||
| mcp_result = await mcp_result | ||
| return mcp_result |
There was a problem hiding this comment.
2. Async mcp errors remain unnormalized 📘 Rule violation ⌂ Architecture
The async MCP path returns bare timeout and transport-error strings, while the sync path normalizes
the same failures into structured {"error": ..., "timeout": true} dictionaries. As a result, async
retry, circuit-breaking, and loop-detection logic can treat MCP initialization or tool timeouts as
successful ordinary output, bypassing handling that expects error and timeout fields.
Agent Prompt
## Issue description
Async MCP initialization and tool timeout results bypass the error normalization used by synchronous MCP execution, causing bare failure strings to be treated as successful tool output by async error-handling logic.
## Issue Context
Move MCP result normalization into a shared helper and apply it after awaiting the result in the async path so sync and async execution preserve identical result shapes. Preserve the existing narrow matching of MCP's internally generated timeout signatures; legitimate successful user output beginning with `Error:` must remain unchanged.
## Fix Focus Areas
- src/praisonai-agents/praisonaiagents/agent/execution_mixin.py[1722-1726]
- src/praisonai-agents/praisonaiagents/agent/tool_execution.py[2588-2617]
- src/praisonai-agents/praisonaiagents/agent/execution_mixin.py[1587-1652]
- src/praisonai-agents/praisonaiagents/mcp/mcp.py[184-212]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| _parsed_calls.append((function_name, arguments, tool_call_id)) | ||
| _dispatch_specs.append((function_name, arguments, tool_call_id)) | ||
| _batch_results = await _dispatch_tool_batch(_dispatch_specs, iteration_count) | ||
| for (function_name, arguments, tool_call_id), tool_result in zip(_parsed_calls, _batch_results): |
There was a problem hiding this comment.
3. Responses tool messages reorder 📘 Rule violation ≡ Correctness
The Responses API loop appends malformed-call errors during parsing but postpones valid results until after batch dispatch. A mixed batch such as valid A followed by malformed B therefore emits B's error before A's result, changing the existing provider-facing tool-message order.
Agent Prompt
## Issue description
The Responses API batching implementation reorders parse-error messages ahead of earlier valid tool results.
## Issue Context
Build and replay an ordered call plan, as the chat-completions branch already does, while dispatching only valid calls in the batch.
## Fix Focus Areas
- src/praisonai-agents/praisonaiagents/llm/llm.py[4814-4828]
- src/praisonai-agents/praisonaiagents/llm/llm.py[5067-5097]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| execute_tool_fn: Optional[Callable] = None, | ||
| max_tool_calls_per_turn: int = 10, # Loop guardrails | ||
| stream: bool = True, | ||
| parallel_tool_calls: bool = False, |
There was a problem hiding this comment.
4. Behavior changes lack tests 📘 Rule violation ▣ Testability
The PR changes async-tool execution, MCP resolution, hook synchronization, and parallel batching without adding or modifying any automated test file in the supplied diff. Existing test files therefore do not satisfy the requirement for corresponding tests in this PR.
Agent Prompt
## Issue description
Executable behavior was changed without corresponding test modifications in the PR diff.
## Issue Context
Add deterministic regression tests covering sync execution of coroutine tools, async MCP resolution and error normalization, mixed valid/malformed Responses API ordering, parallel dispatch, and concurrent HookRegistry access.
## Fix Focus Areas
- src/praisonai-agents/praisonaiagents/agent/execution_mixin.py[1715-1777]
- src/praisonai-agents/praisonaiagents/agent/tool_execution.py[2684-2733]
- src/praisonai-agents/praisonaiagents/hooks/registry.py[53-60]
- src/praisonai-agents/praisonaiagents/llm/llm.py[4580-4629]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| if parallel_tool_calls and len(dispatch_specs) > 1: | ||
| return await asyncio.gather(*( | ||
| _dispatch_async_tool(execute_tool_fn, fn, args, tc_id, iteration_index) | ||
| for (fn, args, tc_id) in dispatch_specs | ||
| )) |
There was a problem hiding this comment.
5. Failed batches leave tools running 🐞 Bug ☼ Reliability
When one parallel tool raises, asyncio.gather propagates immediately without settling the other already-started dispatches, so sibling tools can keep producing external side effects after achat() has failed and rolled back its history. Their results and tool-call bookkeeping are also discarded because result processing only begins after the batch await succeeds.
Agent Prompt
## Issue description
A raised parallel tool dispatch lets sibling calls outlive the failed LLM request and discards their results.
## Issue Context
Ensure every started dispatch is settled before the batch exits. Preserve intentional fatal exceptions such as `ToolExecutionError`, but only propagate them after sibling tasks have completed or been safely cancelled; account for synchronous tools already running in worker threads.
## Fix Focus Areas
- src/praisonai-agents/praisonaiagents/llm/llm.py[4610-4629]
- src/praisonai-agents/praisonaiagents/llm/llm.py[5089-5099]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| # Snapshot under the lock so callers never iterate the live, mutable | ||
| # list (a concurrent unregister()/clear() could otherwise raise | ||
| # "list changed size during iteration" or skip/duplicate a hook). | ||
| with self._lock: | ||
| hooks = list(self._hooks.get(event, [])) |
There was a problem hiding this comment.
6. Hook listing remains racy 🐞 Bug ☼ Reliability
The new locking discipline snapshots get_hooks() but leaves public list_hooks() iterating live hook lists and reading hook state without the lock. Concurrent register, unregister, clear, enable, or disable operations can therefore produce skipped, newly injected, or internally inconsistent API listings.
Agent Prompt
## Issue description
`list_hooks()` remains an unlocked reader despite the newly introduced registry mutation lock.
## Issue Context
Create the complete listing or a sufficient immutable snapshot under `self._lock`. Apply the same locking discipline to other public readers such as `__len__` and to shared registry settings where atomic snapshots are required.
## Fix Focus Areas
- src/praisonai-agents/praisonaiagents/hooks/registry.py[247-259]
- src/praisonai-agents/praisonaiagents/hooks/registry.py[279-301]
- src/praisonai-agents/praisonaiagents/hooks/registry.py[327-329]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| found, mcp_result = resolve_mcp(function_name, arguments) | ||
| if found: | ||
| if inspect.isawaitable(mcp_result): | ||
| mcp_result = await mcp_result | ||
| return mcp_result |
There was a problem hiding this comment.
7. Mcp bypasses async safety controls 🐞 Bug ☼ Reliability
A resolved MCP result returns at line 1726 before the async path’s configured tool timeout, circuit-breaker precheck and execution, loop guard, and post-execution outcome recording. Consequently, repeated, failing, or hung MCP calls through achat() bypass the reliability controls applied to native async tools, and failures cannot accumulate toward either safeguard.
Agent Prompt
## Issue description
The newly added MCP fast path returns before the normal async invocation and result-processing flow. This bypasses the configured tool timeout, per-tool circuit breaker, loop-guard enforcement, and post-execution outcome recording for MCP tools.
## Issue Context
MCP result resolution must remain available to async callers and retain approval and policy handling, but MCP execution and result handling should pass through the same precheck, timeout, circuit-breaker, loop-guard, and outcome-recording lifecycle as native async tools.
## Fix Focus Areas
- src/praisonai-agents/praisonaiagents/agent/execution_mixin.py[1715-1726]
- src/praisonai-agents/praisonaiagents/agent/execution_mixin.py[1779-1810]
- src/praisonai-agents/praisonaiagents/agent/execution_mixin.py[1895-2008]
- src/praisonai-agents/praisonaiagents/agent/execution_mixin.py[2010-2030]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
…lings
- Async path (_execute_tool_async_impl) now applies the shared
_normalize_mcp_result so an MCP timeout classifies as a retryable
{"error":..., "timeout": True} dict instead of being handed to the
model as a bare "Error: ..." string (Greptile P1, valid parity gap).
- Extracted _normalize_mcp_result to a shared staticmethod so sync and
async paths cannot diverge again.
- Parallel async tool dispatch uses asyncio.gather(return_exceptions=True)
and re-raises the first error only after all siblings settle, so a
single failing tool no longer cancels/orphans in-flight sibling
coroutines while preserving fail-fast propagation.
Co-authored-by: Mervin Praison <[email protected]>
Fixes #4633
Summary
Fixes three verified core-SDK correctness gaps in
src/praisonai-agents/praisonaiagents, keeping the change minimal and shared between the sync/async paths.Gap 1 — silent async-tool coroutine leak (highest priority)
agent/tool_execution.py:_execute_tool_impl(the default OpenAI-native tool loop forAgent(llm="...", tools=[...])) never awaitedasync deftools — it returned a bare coroutine, so the model was handed"<coroutine object ...>"and the tool body never ran. Now any coroutine returned byBaseTool.run/ LangChain.run/ CrewAI._run/ a plain function is awaited via the existing_run_async_in_sync_contextbridge.Gap 2a —
parallel_tool_callsignored byget_response_asyncAdded the parameter to
get_response_asyncand dispatch independent tool calls concurrently viaasyncio.gather(mirroring the synccreate_tool_call_executor(parallel=True)), preserving call order for message building. Previously it was silently absorbed by**kwargs, soachat()/astart()ran tools one-at-a-time.Gap 2b — async tool-resolution parity
execute_tool_asyncnow shares MCP resolution and hallucinated-name self-repair with the sync path via new_resolve_mcp_tool_result/_self_repair_tool_namehelpers (the sync path was refactored to use them too), and returns the same corrective error (available tools + closest match) on a miss.Gap 3b —
HookRegistryunsynchronized mutationAdded an instance
RLock;register/unregister/clear/enable_hook/disable_hookmutate under it andget_hookssnapshots the list under it, eliminating thelist changed size during iterationrace on the process-wide default registry.Gap 3a — intentionally deferred
A per-agent display-callback registry requires threading agent identity through the public
register_display_callbackAPI and every display callsite, expanding surface area against the lightweight-core mandate. The existing "first mode wins" guard already mitigates the primary clobber, so this was left out to avoid scope creep.Test plan
get_response_asyncsignature exposesparallel_tool_calls(Gap 2a)WebSearch->web_searchand returnsavailable_toolson a miss (Gap 2b)get_hooksno longer races (Gap 3b)tests/unit/hooks/,tests/test_parallel_tools.py,tests/test_parallel_loop.pypass (121 passed, 1 skipped)🤖 Generated with Claude Code